Compare commits
97 Commits
v0.0.2
...
adc34a116b
| Author | SHA1 | Date | |
|---|---|---|---|
|
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
|
|||
|
b3ce8e59cb
|
|||
|
55071318ca
|
|||
|
b66b63101f
|
|||
|
9db1b4d97c
|
|||
|
71a8c2e8d2
|
|||
|
88738715b6
|
|||
|
53c650d4b0
|
|||
|
deb6a0b8ed
|
|||
|
923d09d713
|
|||
|
d752898865
|
|||
|
435438aaa8
|
|||
|
084aadccef
|
|||
|
468569fa27
|
|||
|
0986d04ea6
|
|||
|
6ff1a69e2b
|
|||
|
052cae2c2e
|
|||
|
29170f9e13
|
|||
|
25ed6df62a
|
|||
|
2f86700fb7
|
|||
|
e7a79736b7
|
|||
|
2d585d499e
|
|||
|
284d5ffcb4
|
|||
|
27a476ae00
|
|||
|
ee7f79550c
|
|||
|
2ef801905b
|
|||
|
752421c9fc
|
|||
|
ce169f6a61
|
|||
|
622b9fc82d
|
|||
|
275f23c421
|
|||
|
88ed4caf5b
|
|||
|
346e395e15
|
|||
|
f30848803b
|
|||
|
96dab93483
|
|||
|
a6abee1ddf
|
|||
|
b20f2bffd6
|
|||
|
f6689cbc5c
|
|||
|
8383605115
|
|||
|
f69614d5c7
|
|||
|
f7902011cc
|
|||
|
e86876ba69
|
|||
|
cd6f2e3ba2
|
|||
|
66e2169f45
|
|||
|
489cc2646b
|
|||
|
295f1f7449
|
|||
|
33a1bc24f6
|
|||
|
d18780bb21
|
|||
|
ef569ac3b1
|
|||
|
9390b7035c
|
|||
|
ac1730401a
|
|||
|
bc41b1a7a1
|
|||
|
ea566d4a42
|
|||
|
573e327a0f
|
|||
|
831e81e892
|
|||
|
39a6ab5fcc
|
@@ -1,5 +1,5 @@
|
||||
name: Build BAB Application Deployment Artifact
|
||||
run-name: ${{ gitea.actor }} is building an artifact 🚀
|
||||
run-name: ${{ gitea.actor }} is building a BAB App artifact 🚀
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
@@ -15,16 +15,37 @@ jobs:
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: '20.x'
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
- name: Install yarn
|
||||
run: npm install --global yarn
|
||||
- name: Install yarn dependencies
|
||||
run: yarn install
|
||||
- name: Install Quasar CLI
|
||||
run: npm install -g @quasar/cli
|
||||
run: yarn global add @quasar/cli
|
||||
- name: Temporary - Invoke custom qcalendar build
|
||||
run: quasar ext invoke @quasar/qcalendar
|
||||
- name: Create env file
|
||||
run: |
|
||||
echo "${{ vars.ENV_FILE }}" > .env.local
|
||||
- name: Show env file
|
||||
run: |
|
||||
/bin/cat .env.local
|
||||
- name: Build Project
|
||||
run: quasar build -m pwa
|
||||
# - name: Archive Production Artifact
|
||||
# uses: actions/upload-artifact@v2
|
||||
# with:
|
||||
# name: build-artifact
|
||||
# path: dist/pwa
|
||||
- name: Get Version Number
|
||||
id: get_version
|
||||
run: echo "::set-output name=VERSION::$(node -p "require('./package.json').version")"
|
||||
- name: Tarfile
|
||||
run: |
|
||||
cd dist/pwa
|
||||
tar czf ../../build-${{ steps.get_version.outputs.VERSION }}.tar.gz .
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: build-artifact-${{ steps.get_version.outputs.VERSION }}.${{ gitea.run_number }}
|
||||
path: build-${{ steps.get_version.outputs.VERSION }}.tar.gz
|
||||
- name: Trigger Ansible Deploy Playbook
|
||||
uses: https://github.com/distributhor/workflow-webhook@v3
|
||||
with:
|
||||
webhook_url: ${{ vars.WEBHOOK_URL }}
|
||||
verbose: true
|
||||
data: '{ "artifact_url": "${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id}}/artifacts/build-artifact-${{ steps.get_version.outputs.VERSION }}.${{ gitea.run_number }}" }'
|
||||
|
||||
4
appwrite.json
Normal file
4
appwrite.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"projectId": "65ede55a213134f2b688",
|
||||
"projectName": ""
|
||||
}
|
||||
3
backup/.env
Normal file
3
backup/.env
Normal file
@@ -0,0 +1,3 @@
|
||||
APPWRITE_ENDPOINT=https://apidev.bab.toal.ca/v1
|
||||
APPWRITE_PROJECT_ID=65ede55a213134f2b688
|
||||
APPWRITE_API_KEY=71f7f899ca605b39a3f24a80a23b34f580fd7e735316152bc0d5ed042bd452e7116c4d0a7f3c77d343690d6cce229020c76de1733c754a402f15bbe9b2cab5a6cd7b3a7c1c0d66cede4f6aee99cdfac14898b7a2006a5eaae24529bbcb19b4c2f6563adff5688dda9c15357c9e98b449e50b6794dfb8cc6ab61e9f073b08a11e
|
||||
4
backup/appwrite.json
Normal file
4
backup/appwrite.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"projectId": "65ede55a213134f2b688",
|
||||
"projectName": ""
|
||||
}
|
||||
8
docs/planning/personas.md
Normal file
8
docs/planning/personas.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# Personas
|
||||
|
||||
- BAB Member
|
||||
- Certified Skipper
|
||||
- Program Administrator
|
||||
- Boatswain
|
||||
- Volunteer
|
||||
- Instructor
|
||||
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.
|
||||
40
docs/users_roles_permissions.md
Normal file
40
docs/users_roles_permissions.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# Users, Roles and Permissions
|
||||
|
||||
This is the design document for https://gitea.toal.ca/oys/bab-app/issues/11
|
||||
|
||||
## Backend Concepts
|
||||
|
||||
Utilizing the AppWrite backend provides us with some basic concepts we can use:
|
||||
|
||||
### Users, Groups, and Labels
|
||||
|
||||
#### Teams
|
||||
|
||||
Teams are AppWrite groups of users. Teams can be assigned roles, which can be assigned permissions. Teams "contain" users. A team has more permissions to manage it's members than labels, which are assigned / removed, rather than 'invited / left'.
|
||||
|
||||
#### Labels
|
||||
|
||||
Labels are AppWrite tags for users. Users have Labels as attributes. Like teams, labels can be used for Role / Permission mapping.
|
||||
|
||||
### Permissions
|
||||
|
||||
https://appwrite.io/docs/advanced/platform/permissions
|
||||
|
||||
Permissions are fine-grained access control for users and objects. They follow standard "CRUD" patterns.
|
||||
|
||||
## BAB Concepts
|
||||
|
||||
For teams, there will, to start, be the following:
|
||||
|
||||
- `staff` : Individuals with authority / responsibilities
|
||||
- `maintenance` : Staff responsible for maintenance (eg: Boatswain)
|
||||
- `admin`: Administrators of the program / application
|
||||
- `school` : Members of the Sailing School (Instructors & Students)
|
||||
- `student` role : A student in the school
|
||||
- `instructor` role: An instructor in the school
|
||||
- `bab` : Members of the BAB program
|
||||
- `skipper` role: A member who has passed skipper certification
|
||||
|
||||
The following are the initial labels:
|
||||
|
||||
- TBD
|
||||
27
package.json
27
package.json
@@ -13,35 +13,42 @@
|
||||
"build": "quasar build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@quasar/extras": "^1.16.4",
|
||||
"appwrite": "^13.0.0",
|
||||
"@quasar/extras": "^1.16.11",
|
||||
"@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",
|
||||
"quasar": "^2.6.0",
|
||||
"vue": "^3.0.0",
|
||||
"vue-router": "^4.0.0"
|
||||
"vue": "3",
|
||||
"vue-router": "4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@quasar/app-vite": "^1.3.0",
|
||||
"@quasar/quasar-app-extension-qcalendar": "^4.0.0-beta.15",
|
||||
"@quasar/app-vite": "^1.9.1",
|
||||
"@types/node": "^12.20.21",
|
||||
"@typescript-eslint/eslint-plugin": "^5.10.0",
|
||||
"@typescript-eslint/parser": "^5.10.0",
|
||||
"autoprefixer": "^10.4.2",
|
||||
"dotenv": "^16.3.1",
|
||||
"eslint": "^8.10.0",
|
||||
"eslint-config-prettier": "^8.1.0",
|
||||
"eslint-plugin-vue": "^9.0.0",
|
||||
"prettier": "^2.5.1",
|
||||
"typescript": "^4.5.4",
|
||||
"quasar": "^2.16.0",
|
||||
"typescript": "~5.3.0",
|
||||
"vite-plugin-checker": "^0.6.4",
|
||||
"vue-tsc": "^1.8.22",
|
||||
"workbox-build": "^7.0.0",
|
||||
"workbox-cacheable-response": "^7.0.0",
|
||||
"workbox-core": "^7.0.0",
|
||||
"workbox-expiration": "^7.0.0",
|
||||
"workbox-precaching": "^7.0.0",
|
||||
"workbox-routing": "^7.0.0",
|
||||
"workbox-strategies": "^7.0.0"
|
||||
"workbox-strategies": "^7.0.0",
|
||||
"yarn": "^1.22.21"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18 || ^16 || ^14.19",
|
||||
"node": "^20 || ^18 || ^16 || ^14.19",
|
||||
"npm": ">= 6.13.4",
|
||||
"yarn": ">= 1.21.1"
|
||||
}
|
||||
|
||||
BIN
public/tmpimg/JMI.jpg
Normal file
BIN
public/tmpimg/JMI.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 130 KiB |
@@ -48,11 +48,11 @@ module.exports = configure(function (/* ctx */) {
|
||||
|
||||
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#build
|
||||
build: {
|
||||
env: require('dotenv').config({ path: '.env.local' }).parsed,
|
||||
target: {
|
||||
browser: ['es2019', 'edge88', 'firefox78', 'chrome87', 'safari13.1'],
|
||||
node: 'node16',
|
||||
},
|
||||
|
||||
vueRouterMode: 'hash', // available values: 'hash', 'history'
|
||||
// vueRouterBase,
|
||||
// vueDevtools,
|
||||
@@ -72,9 +72,20 @@ module.exports = configure(function (/* ctx */) {
|
||||
// extendViteConf (viteConf) {},
|
||||
// viteVuePluginOptions: {},
|
||||
|
||||
// vitePlugins: [
|
||||
// [ 'package-name', { ..options.. } ]
|
||||
// ]
|
||||
vitePlugins: [
|
||||
[
|
||||
'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
|
||||
@@ -83,6 +94,21 @@ module.exports = configure(function (/* ctx */) {
|
||||
// open: true, // opens browser window automatically
|
||||
port: 4000,
|
||||
strictport: true,
|
||||
// This works around CORS problems when developing locally, using the Appwrite backend
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'https://apidev.bab.toal.ca/',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||
},
|
||||
'/function': {
|
||||
target: 'https://6640382951eacb568371.f.appwrite.toal.ca/',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
rewrite: (path) => path.replace(/^\/function/, ''),
|
||||
},
|
||||
},
|
||||
// For reverse-proxying via haproxy
|
||||
// hmr: {
|
||||
// clientPort: 443,
|
||||
@@ -93,7 +119,9 @@ module.exports = configure(function (/* ctx */) {
|
||||
|
||||
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#framework
|
||||
framework: {
|
||||
config: {},
|
||||
config: {
|
||||
autoImportComponentCase: 'kebab', // or 'kebab' (default) or 'combined'
|
||||
},
|
||||
|
||||
// iconSet: 'material-icons', // Quasar icon set
|
||||
// lang: 'en-US', // Quasar language pack
|
||||
|
||||
11
src/App.vue
11
src/App.vue
@@ -2,10 +2,15 @@
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
<script setup lang="ts">
|
||||
import { defineComponent, onMounted } from 'vue';
|
||||
import { useAuthStore } from './stores/auth';
|
||||
|
||||
export default defineComponent({
|
||||
defineComponent({
|
||||
name: 'OYS Borrow-a-Boat',
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await useAuthStore().init();
|
||||
});
|
||||
</script>
|
||||
|
||||
BIN
src/assets/OYS-Burgee_square.png
Normal file
BIN
src/assets/OYS-Burgee_square.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.9 KiB |
BIN
src/assets/oysqn_logo_only_bordered.png
Normal file
BIN
src/assets/oysqn_logo_only_bordered.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 80 KiB |
@@ -1,5 +1,5 @@
|
||||
import { boot } from 'quasar/wrappers';
|
||||
import { Client, Account, Databases, ID } from 'appwrite';
|
||||
import { Client, Account, Databases, Functions, ID } from 'appwrite';
|
||||
import { useAuthStore } from 'src/stores/auth';
|
||||
import { Dialog, Notify } from 'quasar';
|
||||
import type { Router } from 'vue-router';
|
||||
@@ -13,14 +13,29 @@ const client = new Client();
|
||||
// const appDatabaseId = '654ac5044d1c446feb71';
|
||||
|
||||
// Private self-hosted appwrite
|
||||
client
|
||||
.setEndpoint('https://apidev.bab.toal.ca/v1')
|
||||
.setProject('655a7116479b4d5a815f');
|
||||
//TODO
|
||||
const appDatabaseId = '';
|
||||
if (process.env.APPWRITE_API_ENDPOINT && process.env.APPWRITE_API_PROJECT)
|
||||
client
|
||||
.setEndpoint(process.env.APPWRITE_API_ENDPOINT)
|
||||
.setProject(process.env.APPWRITE_API_PROJECT);
|
||||
|
||||
//TODO move this to config file
|
||||
const AppwriteIds = {
|
||||
databaseId: '65ee1cbf9c2493faf15f',
|
||||
collection: {
|
||||
boat: '66341910003e287cd71c',
|
||||
reservation: '663f8847000b8f5e29bb',
|
||||
skillTags: '66072582a74d94a4bd01',
|
||||
task: '65ee1cd5b550023fae4f',
|
||||
taskTags: '65ee21d72d5c8007c34c',
|
||||
timeBlock: '66361869002883fb4c4b',
|
||||
timeBlockTemplate: '66361f480007fdd639af',
|
||||
},
|
||||
};
|
||||
|
||||
const account = new Account(client);
|
||||
const databases = new Databases(client);
|
||||
const functions = new Functions(client);
|
||||
|
||||
let appRouter: Router;
|
||||
|
||||
export default boot(async ({ router }) => {
|
||||
@@ -86,4 +101,13 @@ function login(email: string, password: string) {
|
||||
});
|
||||
});
|
||||
}
|
||||
export { client, account, databases, ID, appDatabaseId, login, logout };
|
||||
export {
|
||||
client,
|
||||
account,
|
||||
databases,
|
||||
functions,
|
||||
ID,
|
||||
AppwriteIds,
|
||||
login,
|
||||
logout,
|
||||
};
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<p>{{ title }}</p>
|
||||
<ul>
|
||||
<li v-for="todo in todos" :key="todo.id" @click="increment">
|
||||
{{ todo.id }} - {{ todo.content }}
|
||||
</li>
|
||||
</ul>
|
||||
<p>Count: {{ todoCount }} / {{ meta.totalCount }}</p>
|
||||
<p>Active: {{ active ? 'yes' : 'no' }}</p>
|
||||
<p>Clicks on todos: {{ clickCount }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {
|
||||
defineComponent,
|
||||
PropType,
|
||||
computed,
|
||||
ref,
|
||||
toRef,
|
||||
Ref,
|
||||
} from 'vue';
|
||||
import { Todo, Meta } from './models';
|
||||
|
||||
function useClickCount() {
|
||||
const clickCount = ref(0);
|
||||
function increment() {
|
||||
clickCount.value += 1
|
||||
return clickCount.value;
|
||||
}
|
||||
|
||||
return { clickCount, increment };
|
||||
}
|
||||
|
||||
function useDisplayTodo(todos: Ref<Todo[]>) {
|
||||
const todoCount = computed(() => todos.value.length);
|
||||
return { todoCount };
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ExampleComponent',
|
||||
props: {
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
todos: {
|
||||
type: Array as PropType<Todo[]>,
|
||||
default: () => []
|
||||
},
|
||||
meta: {
|
||||
type: Object as PropType<Meta>,
|
||||
required: true
|
||||
},
|
||||
active: {
|
||||
type: Boolean
|
||||
}
|
||||
},
|
||||
setup (props) {
|
||||
return { ...useClickCount(), ...useDisplayTodo(toRef(props, 'todos')) };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -1,3 +1,4 @@
|
||||
<!-- This has been abandoned for now. Going to block-based booking. Will probably need the schedule viewer functionality at some point in the future, though -->
|
||||
<template>
|
||||
<q-card-section>
|
||||
<div class="text-caption text-justify">
|
||||
@@ -55,11 +56,13 @@
|
||||
v-model="selectedDate"
|
||||
:model-resources="boatStore.boats"
|
||||
resource-key="id"
|
||||
resource-label="name"
|
||||
:interval-start="12"
|
||||
:interval-count="36"
|
||||
:interval-minutes="30"
|
||||
resource-label="displayName"
|
||||
resource-width="32"
|
||||
:interval-start="6"
|
||||
:interval-count="18"
|
||||
:interval-minutes="60"
|
||||
cell-width="48"
|
||||
style="--calendar-resources-width: 48px"
|
||||
resource-min-height="40"
|
||||
animated
|
||||
bordered
|
||||
@@ -79,8 +82,8 @@
|
||||
</template>
|
||||
|
||||
<template #resource-label="{ scope: { resource } }">
|
||||
<div class="col-12">
|
||||
{{ resource.name }}
|
||||
<div class="col-12 .col-md-auto">
|
||||
{{ resource.displayName }}
|
||||
<q-icon v-if="resource.defects" name="warning" color="warning" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -98,31 +101,41 @@
|
||||
><template v-slot:append><q-icon name="timelapse" /></template></q-select
|
||||
></q-card-section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import {
|
||||
QCalendarResource,
|
||||
TimestampOrNull,
|
||||
today,
|
||||
parseDate,
|
||||
parseTimestamp,
|
||||
addToDate,
|
||||
Timestamp,
|
||||
parsed,
|
||||
} from '@quasar/quasar-ui-qcalendar';
|
||||
import { Boat, useBoatStore } from 'src/stores/boat';
|
||||
import { useScheduleStore } from 'src/stores/schedule';
|
||||
import { useReservationStore } from 'src/stores/reservation';
|
||||
import { date } from 'quasar';
|
||||
import { computed } from 'vue';
|
||||
import type { StatusTypes } from 'src/stores/schedule.types';
|
||||
|
||||
interface EventData {
|
||||
event: object;
|
||||
scope: {
|
||||
timestamp: object;
|
||||
columnindex: number;
|
||||
activeDate: boolean;
|
||||
droppable: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const durations = [1, 1.5, 2, 2.5, 3, 3.5, 4];
|
||||
|
||||
type ResourceIntervalScope = {
|
||||
interface ResourceIntervalScope {
|
||||
resource: Boat;
|
||||
intervals: [];
|
||||
timeStartPosX(start: TimestampOrNull): number;
|
||||
timeDurationWidth(duration: number): number;
|
||||
};
|
||||
}
|
||||
|
||||
const statusLookup = {
|
||||
confirmed: ['#14539a', 'white'],
|
||||
@@ -132,7 +145,7 @@ const statusLookup = {
|
||||
|
||||
const calendar = ref();
|
||||
const boatStore = useBoatStore();
|
||||
const scheduleStore = useScheduleStore();
|
||||
const reservationStore = useReservationStore();
|
||||
const selectedDate = ref(today());
|
||||
const duration = ref(1);
|
||||
|
||||
@@ -158,14 +171,14 @@ function monthFormatter() {
|
||||
}
|
||||
|
||||
function getEvents(scope: ResourceIntervalScope) {
|
||||
const resourceEvents = scheduleStore.getBoatReservations(
|
||||
scope.resource.id,
|
||||
date.extractDate(selectedDate.value, 'YYYY-MM-DD')
|
||||
const resourceEvents = reservationStore.getReservationsByDate(
|
||||
selectedDate.value,
|
||||
scope.resource.$id
|
||||
);
|
||||
|
||||
return resourceEvents.map((event) => {
|
||||
return {
|
||||
left: scope.timeStartPosX(parseDate(event.start)),
|
||||
left: scope.timeStartPosX(parsed(event.start)),
|
||||
width: scope.timeDurationWidth(
|
||||
date.getDateDiff(event.end, event.start, 'minutes')
|
||||
),
|
||||
@@ -179,7 +192,7 @@ function getStyle(event: {
|
||||
left: number;
|
||||
width: number;
|
||||
title: string;
|
||||
status: 'tentative' | 'confirmed' | 'pending';
|
||||
status: StatusTypes;
|
||||
}) {
|
||||
return {
|
||||
position: 'absolute',
|
||||
@@ -200,14 +213,16 @@ function onPrev() {
|
||||
function onNext() {
|
||||
calendar.value.next();
|
||||
}
|
||||
function onClickDate(data) {
|
||||
return;
|
||||
|
||||
function onClickDate(data: EventData) {
|
||||
return data;
|
||||
}
|
||||
function onClickTime(data) {
|
||||
|
||||
function onClickTime(data: EventData) {
|
||||
// TODO: Add a duration picker, here.
|
||||
emit('onClickTime', data);
|
||||
}
|
||||
function onUpdateDuration(value) {
|
||||
function onUpdateDuration(value: EventData) {
|
||||
emit('onUpdateDuration', value);
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<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-item-section>
|
||||
</template>
|
||||
|
||||
@@ -1,23 +1,26 @@
|
||||
<template>
|
||||
<q-card v-for="boat in boats" :key="boat.id" flat class="mobile-card">
|
||||
<q-card-section>
|
||||
<q-img :src="boat.imgsrc" :fit="'scale-down'">
|
||||
<div class="row absolute-top">
|
||||
<div class="col text-h5 text-left">{{ boat.name }}</div>
|
||||
<div class="col text-right">{{ boat.class }}</div>
|
||||
</div>
|
||||
</q-img>
|
||||
</q-card-section>
|
||||
<div v-if="boats">
|
||||
<q-card v-for="boat in boats" :key="boat.id" flat class="mobile-card">
|
||||
<q-card-section>
|
||||
<q-img :src="boat.imgSrc" :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-btn flat>Info</q-btn>
|
||||
<q-btn flat>Book</q-btn>
|
||||
<q-btn flat>Check-Out</q-btn>
|
||||
<q-btn flat>Check-In</q-btn>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
<q-card-actions align="evenly">
|
||||
<q-btn flat>Info</q-btn>
|
||||
<q-btn flat>Book</q-btn>
|
||||
<q-btn flat>Check-Out</q-btn>
|
||||
<q-btn flat>Check-In</q-btn>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</div>
|
||||
<div v-else><q-card>Sorry, no boats to show you!</q-card></div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
0
src/components/scheduling/BoatSelection.vue
Normal file
0
src/components/scheduling/BoatSelection.vue
Normal file
172
src/components/scheduling/IntervalTemplateComponent.vue
Normal file
172
src/components/scheduling/IntervalTemplateComponent.vue
Normal file
@@ -0,0 +1,172 @@
|
||||
<template>
|
||||
<q-expansion-item
|
||||
expand-icon-toggle
|
||||
draggable="true"
|
||||
@dragstart="onDragStart($event, template)"
|
||||
v-model="expanded"
|
||||
>
|
||||
<template v-slot:header>
|
||||
<q-item-section>
|
||||
<q-input
|
||||
label="Template name"
|
||||
:borderless="!edit"
|
||||
dense
|
||||
v-model="template.name"
|
||||
v-if="edit"
|
||||
/><q-item-label v-if="!edit" class="cursor-pointer">{{
|
||||
template.name
|
||||
}}</q-item-label></q-item-section
|
||||
>
|
||||
</template>
|
||||
<q-card flat>
|
||||
<q-card-section horizontal>
|
||||
<q-card-section class="q-pt-xs">
|
||||
<q-list dense>
|
||||
<q-item v-for="(item, index) in template.timeTuples" :key="item[0]">
|
||||
<q-input
|
||||
class="q-mx-sm"
|
||||
dense
|
||||
v-model="item[0]"
|
||||
type="time"
|
||||
label="Start"
|
||||
:borderless="!edit"
|
||||
:readonly="!edit" />
|
||||
<q-input
|
||||
class="q-mx-sm"
|
||||
dense
|
||||
v-model="item[1]"
|
||||
type="time"
|
||||
label="End"
|
||||
:borderless="!edit"
|
||||
:readonly="!edit"
|
||||
>
|
||||
<template v-slot:after>
|
||||
<q-btn
|
||||
v-if="edit"
|
||||
round
|
||||
dense
|
||||
flat
|
||||
icon="delete"
|
||||
@click="template.timeTuples.splice(index, 1)"
|
||||
/> </template></q-input></q-item
|
||||
></q-list>
|
||||
<q-btn
|
||||
v-if="edit"
|
||||
dense
|
||||
color="primary"
|
||||
size="sm"
|
||||
label="Add interval"
|
||||
@click="template.timeTuples.push(['00:00', '00:00'])"
|
||||
/></q-card-section>
|
||||
<q-card-actions vertical>
|
||||
<q-btn
|
||||
v-if="!edit"
|
||||
color="primary"
|
||||
icon="edit"
|
||||
label="Edit"
|
||||
@click="toggleEdit"
|
||||
/>
|
||||
<q-btn
|
||||
v-if="edit"
|
||||
color="primary"
|
||||
icon="save"
|
||||
label="Save"
|
||||
@click="saveTemplate($event, template)"
|
||||
/>
|
||||
<q-btn
|
||||
v-if="edit"
|
||||
color="secondary"
|
||||
icon="cancel"
|
||||
label="Cancel"
|
||||
@click="revert"
|
||||
/>
|
||||
<q-btn
|
||||
color="negative"
|
||||
icon="delete"
|
||||
label="Delete"
|
||||
v-if="template.$id !== ''"
|
||||
@click="deleteTemplate($event, template)"
|
||||
/>
|
||||
</q-card-actions>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-dialog v-model="alert">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="text-h6">Overlapped blocks!</div>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pt-none">
|
||||
<q-chip
|
||||
square
|
||||
icon="schedule"
|
||||
v-for="item in overlapped"
|
||||
:key="item.start"
|
||||
>
|
||||
{{ item.start }}-{{ item.end }}</q-chip
|
||||
>
|
||||
</q-card-section>
|
||||
<q-card-actions align="right">
|
||||
<q-btn flat label="OK" color="primary" v-close-popup />
|
||||
</q-card-actions> </q-card
|
||||
></q-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
copyIntervalTemplate,
|
||||
timeTuplesOverlapped,
|
||||
useScheduleStore,
|
||||
} from 'src/stores/schedule';
|
||||
import { IntervalTemplate } from 'src/stores/schedule.types';
|
||||
import { ref } from 'vue';
|
||||
const alert = ref(false);
|
||||
const overlapped = ref();
|
||||
const scheduleStore = useScheduleStore();
|
||||
const props = defineProps<{ edit?: boolean; modelValue: IntervalTemplate }>();
|
||||
const edit = ref(props.edit);
|
||||
const expanded = ref(props.edit);
|
||||
const template = ref(copyIntervalTemplate(props.modelValue));
|
||||
|
||||
const emit = defineEmits<{ (e: 'cancel'): void; (e: 'saved'): void }>();
|
||||
|
||||
const revert = () => {
|
||||
template.value = copyIntervalTemplate(props.modelValue);
|
||||
edit.value = false;
|
||||
emit('cancel');
|
||||
};
|
||||
|
||||
const toggleEdit = () => {
|
||||
edit.value = !edit.value;
|
||||
};
|
||||
|
||||
const deleteTemplate = (
|
||||
event: Event,
|
||||
template: IntervalTemplate | undefined
|
||||
) => {
|
||||
if (template?.$id) scheduleStore.deleteIntervalTemplate(template.$id);
|
||||
};
|
||||
|
||||
function onDragStart(e: DragEvent, template: IntervalTemplate) {
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('ID', template.$id || '');
|
||||
}
|
||||
}
|
||||
const saveTemplate = (evt: Event, template: IntervalTemplate | undefined) => {
|
||||
if (!template) return false;
|
||||
overlapped.value = timeTuplesOverlapped(template.timeTuples);
|
||||
if (overlapped.value.length > 0) {
|
||||
alert.value = true;
|
||||
} else {
|
||||
edit.value = false;
|
||||
if (template.$id && template.$id !== 'unsaved') {
|
||||
scheduleStore.updateIntervalTemplate(template, template.$id);
|
||||
} else {
|
||||
scheduleStore.createIntervalTemplate(template);
|
||||
emit('saved');
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
19
src/components/scheduling/NavigationBar.vue
Normal file
19
src/components/scheduling/NavigationBar.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<div class="row justify-center">
|
||||
<div class="q-pa-md q-gutter-sm row">
|
||||
<q-btn no-caps class="button" style="margin: 2px" @click="$emit('today')">
|
||||
Today
|
||||
</q-btn>
|
||||
<q-btn no-caps class="button" style="margin: 2px" @click="$emit('prev')">
|
||||
< Prev
|
||||
</q-btn>
|
||||
<q-btn no-caps class="button" style="margin: 2px" @click="$emit('next')">
|
||||
Next >
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineEmits(['today', 'prev', 'next']);
|
||||
</script>
|
||||
284
src/components/scheduling/boat/BoatScheduleTableComponent.vue
Normal file
284
src/components/scheduling/boat/BoatScheduleTableComponent.vue
Normal file
@@ -0,0 +1,284 @@
|
||||
<template>
|
||||
<div>
|
||||
<CalendarHeaderComponent v-model="selectedDate" />
|
||||
<div class="boat-schedule-table-component">
|
||||
<QCalendarDay
|
||||
ref="calendar"
|
||||
class="q-pa-xs"
|
||||
flat
|
||||
animated
|
||||
dense
|
||||
:disabled-before="disabledBefore"
|
||||
interval-height="24"
|
||||
interval-count="18"
|
||||
interval-start="06:00"
|
||||
:short-interval-label="true"
|
||||
v-model="selectedDate"
|
||||
:column-count="boats.length"
|
||||
v-touch-swipe.left.right="handleSwipe"
|
||||
>
|
||||
<template #head-day="{ scope }">
|
||||
<div style="text-align: center; font-weight: 800">
|
||||
{{ getBoatDisplayName(scope) }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #day-body="{ scope }">
|
||||
<div v-for="block in getBoatBlocks(scope)" :key="block.$id">
|
||||
<div
|
||||
class="timeblock"
|
||||
:class="selectedBlock?.$id === block.$id ? 'selected' : ''"
|
||||
:style="
|
||||
blockStyles(block, scope.timeStartPos, scope.timeDurationHeight)
|
||||
"
|
||||
:id="block.id"
|
||||
@click="selectBlock($event, scope, block)"
|
||||
>
|
||||
{{ boats[scope.columnIndex].name }}<br />
|
||||
{{ selectedBlock?.$id === block.$id ? 'Selected' : 'Available' }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-for="reservation in getBoatReservations(scope)"
|
||||
:key="reservation.$id"
|
||||
>
|
||||
<div
|
||||
class="reservation"
|
||||
:style="
|
||||
reservationStyles(
|
||||
reservation,
|
||||
scope.timeStartPos,
|
||||
scope.timeDurationHeight
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ getUserName(reservation.user) || 'loading...' }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</QCalendarDay>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
QCalendarDay,
|
||||
Timestamp,
|
||||
diffTimestamp,
|
||||
today,
|
||||
parseTimestamp,
|
||||
parseDate,
|
||||
addToDate,
|
||||
} from '@quasar/quasar-ui-qcalendar';
|
||||
import CalendarHeaderComponent from './CalendarHeaderComponent.vue';
|
||||
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useBoatStore } from 'src/stores/boat';
|
||||
import { useScheduleStore } from 'src/stores/schedule';
|
||||
import { useAuthStore } from 'src/stores/auth';
|
||||
import { Interval, Reservation } from 'src/stores/schedule.types';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useReservationStore } from 'src/stores/reservation';
|
||||
|
||||
const scheduleStore = useScheduleStore();
|
||||
const reservationStore = useReservationStore();
|
||||
const { boats } = storeToRefs(useBoatStore());
|
||||
const selectedBlock = defineModel<Interval | null>();
|
||||
const selectedDate = ref(today());
|
||||
|
||||
const calendar = ref<QCalendarDay | null>(null);
|
||||
|
||||
onMounted(async () => {
|
||||
await useBoatStore().fetchBoats();
|
||||
await scheduleStore.fetchIntervals();
|
||||
await scheduleStore.fetchIntervalTemplates();
|
||||
});
|
||||
|
||||
function handleSwipe({ ...event }) {
|
||||
event.direction === 'right' ? calendar.value?.prev() : calendar.value?.next();
|
||||
}
|
||||
function reservationStyles(
|
||||
reservation: Reservation,
|
||||
timeStartPos: (t: string) => string,
|
||||
timeDurationHeight: (d: number) => string
|
||||
) {
|
||||
return genericBlockStyle(
|
||||
parseDate(new Date(reservation.start)) as Timestamp,
|
||||
parseDate(new Date(reservation.end)) as Timestamp,
|
||||
timeStartPos,
|
||||
timeDurationHeight
|
||||
);
|
||||
}
|
||||
|
||||
function getUserName(userid: string) {
|
||||
return useAuthStore().getUserNameById(userid);
|
||||
}
|
||||
|
||||
function blockStyles(
|
||||
block: Interval,
|
||||
timeStartPos: (t: string) => string,
|
||||
timeDurationHeight: (d: number) => string
|
||||
) {
|
||||
return genericBlockStyle(
|
||||
parseDate(new Date(block.start)) as Timestamp,
|
||||
parseDate(new Date(block.end)) as Timestamp,
|
||||
timeStartPos,
|
||||
timeDurationHeight
|
||||
);
|
||||
}
|
||||
|
||||
function getBoatDisplayName(scope: DayBodyScope) {
|
||||
return boats && boats.value[scope.columnIndex]
|
||||
? boats.value[scope.columnIndex].displayName
|
||||
: '';
|
||||
}
|
||||
|
||||
function genericBlockStyle(
|
||||
start: Timestamp,
|
||||
end: Timestamp,
|
||||
timeStartPos: (t: string) => string,
|
||||
timeDurationHeight: (d: number) => string
|
||||
) {
|
||||
const s = {
|
||||
top: '',
|
||||
height: '',
|
||||
opacity: '',
|
||||
};
|
||||
if (timeStartPos && timeDurationHeight) {
|
||||
s.top = timeStartPos(start.time) + 'px';
|
||||
s.height =
|
||||
parseInt(
|
||||
timeDurationHeight(diffTimestamp(start, end, false) / 1000 / 60)
|
||||
) -
|
||||
1 +
|
||||
'px';
|
||||
}
|
||||
// if (selectedBlock.value?.id === block.id) {
|
||||
// s.opacity = '1.0';
|
||||
// }
|
||||
return s;
|
||||
}
|
||||
|
||||
interface DayBodyScope {
|
||||
columnIndex: number;
|
||||
timeDurationHeight: string;
|
||||
timeStartPos: (time: string, clamp: boolean) => string;
|
||||
timestamp: Timestamp;
|
||||
}
|
||||
|
||||
function selectBlock(event: MouseEvent, scope: DayBodyScope, block: Interval) {
|
||||
// TODO: Disable blocks before today with updateDisabled and/or comparison
|
||||
selectedBlock.value === block
|
||||
? (selectedBlock.value = null)
|
||||
: (selectedBlock.value = block);
|
||||
}
|
||||
|
||||
const boatBlocks = computed((): Record<string, Interval[]> => {
|
||||
return scheduleStore
|
||||
.getIntervalsForDate(selectedDate.value)
|
||||
.reduce((result, tb) => {
|
||||
if (!result[tb.boatId]) result[tb.boatId] = [];
|
||||
result[tb.boatId].push(tb);
|
||||
return result;
|
||||
}, <Record<string, Interval[]>>{});
|
||||
});
|
||||
|
||||
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 getBoatBlocks(scope: DayBodyScope): Interval[] {
|
||||
const boat = boats.value[scope.columnIndex];
|
||||
return boat ? boatBlocks.value[boat.$id] : [];
|
||||
}
|
||||
function getBoatReservations(scope: DayBodyScope): Reservation[] {
|
||||
const boat = boats.value[scope.columnIndex];
|
||||
return boat ? boatReservations.value[boat.$id] : [];
|
||||
}
|
||||
|
||||
// function changeEvent({ start }: { start: string }) {
|
||||
// const newBlocks = scheduleStore.getIntervalsForDate(start);
|
||||
// const reservations = scheduleStore.getReservationsByDate(
|
||||
// parsed(start) as Timestamp
|
||||
// );
|
||||
// boats.value.map((boat) => {
|
||||
// boat.reservations = reservations.filter(
|
||||
// (reservation) => reservation.resource === boat
|
||||
// );
|
||||
// boat.blocks = newBlocks.filter(
|
||||
// (block) =>
|
||||
// block.boatId === boat.$id &&
|
||||
// boat.reservations?.filter(
|
||||
// (r: Reservation) =>
|
||||
// r.start <
|
||||
// date.addToDate(makeDateTime(parsed(block.end) as Timestamp), {
|
||||
// hours: 4,
|
||||
// }) &&
|
||||
// r.end >
|
||||
// date.addToDate(makeDateTime(parsed(block.start) as Timestamp), {
|
||||
// hours: 4,
|
||||
// })
|
||||
// ).length == 0
|
||||
// );
|
||||
// });
|
||||
// setTimeout(() => calendar.value?.scrollToTime('09:00'), 100); // Should figure out why we need this setTimeout...
|
||||
// }
|
||||
|
||||
const disabledBefore = computed(() => {
|
||||
const todayTs = parseTimestamp(today()) as Timestamp;
|
||||
return addToDate(todayTs, { day: -1 }).date;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="sass">
|
||||
.boat-schedule-table-component
|
||||
display: flex
|
||||
max-height: 60vh
|
||||
max-width: 98vw
|
||||
.reservation
|
||||
display: flex
|
||||
position: absolute
|
||||
justify-content: center
|
||||
align-items: center
|
||||
text-align: center
|
||||
width: 100%
|
||||
opacity: 1
|
||||
margin: 0px
|
||||
text-overflow: ellipsis
|
||||
font-size: 0.8em
|
||||
cursor: pointer
|
||||
background: $accent
|
||||
color: white
|
||||
border: 1px solid black
|
||||
.timeblock
|
||||
display: flex
|
||||
position: absolute
|
||||
justify-content: center
|
||||
text-align: center
|
||||
align-items: center
|
||||
width: 100%
|
||||
opacity: 0.5
|
||||
margin: 0px
|
||||
text-overflow: ellipsis
|
||||
font-size: 0.8em
|
||||
cursor: pointer
|
||||
background: $primary
|
||||
color: white
|
||||
border: 1px solid black
|
||||
.selected
|
||||
opacity: 1 !important
|
||||
.q-calendar-day__interval--text
|
||||
font-size: 0.8em
|
||||
.q-calendar-day__day.q-current-day
|
||||
padding: 1px
|
||||
.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>
|
||||
266
src/components/task/TaskEditComponent.vue
Normal file
266
src/components/task/TaskEditComponent.vue
Normal file
@@ -0,0 +1,266 @@
|
||||
<template>
|
||||
<q-form @submit="onSubmit" class="q-gutter-md">
|
||||
<q-input
|
||||
filled
|
||||
v-model="modifiedTask.title"
|
||||
label="Task Title"
|
||||
hint="A short description of the task"
|
||||
lazy-rules
|
||||
:rules="[
|
||||
(val: string | any[]) =>
|
||||
(val && val.length > 0) || 'Please enter a title for the task',
|
||||
]"
|
||||
/>
|
||||
<q-editor
|
||||
filled
|
||||
v-model="modifiedTask.description"
|
||||
label="Detailed Description"
|
||||
hint="A detailed description of the task"
|
||||
lazy-rules
|
||||
placeholder="Enter a detailed description..."
|
||||
/>
|
||||
|
||||
<q-input
|
||||
filled
|
||||
v-model="modifiedTask.due_date"
|
||||
mask="date"
|
||||
:rules="[dateRule]"
|
||||
hint="Enter the due date"
|
||||
lazy-rules
|
||||
>
|
||||
<template v-slot:append>
|
||||
<q-icon name="event" class="cursor-pointer">
|
||||
<q-popup-proxy cover transition-show="scale" transition-hide="scale">
|
||||
<q-date
|
||||
v-model="modifiedTask.due_date"
|
||||
@input="updateDateISO"
|
||||
today-btn
|
||||
>
|
||||
<div class="row items-center justify-end">
|
||||
<q-btn v-close-popup label="Close" color="primary" flat />
|
||||
</div>
|
||||
</q-date>
|
||||
</q-popup-proxy>
|
||||
</q-icon>
|
||||
</template>
|
||||
</q-input>
|
||||
<div>
|
||||
<q-select
|
||||
label="Skills Required"
|
||||
hint="Add a list of required skills, to help people find things in their ability"
|
||||
v-model="modifiedTask.required_skills"
|
||||
use-input
|
||||
use-chips
|
||||
multiple
|
||||
clearable
|
||||
emit-value
|
||||
map-options
|
||||
input-debounce="250"
|
||||
:options="skillTagOptions"
|
||||
option-label="name"
|
||||
option-value="$id"
|
||||
@filter="filterSkillTags"
|
||||
>
|
||||
</q-select>
|
||||
</div>
|
||||
<div>
|
||||
<q-select
|
||||
label="Tags"
|
||||
hint="Add Tags to help with searching"
|
||||
v-model="modifiedTask.tags"
|
||||
use-input
|
||||
use-chips
|
||||
multiple
|
||||
clearable
|
||||
emit-value
|
||||
map-options
|
||||
input-debounce="250"
|
||||
:options="taskTagOptions"
|
||||
option-label="name"
|
||||
option-value="$id"
|
||||
@filter="filterTaskTags"
|
||||
>
|
||||
</q-select>
|
||||
</div>
|
||||
<q-input
|
||||
label="Estimated Duration"
|
||||
v-model.number="modifiedTask.duration"
|
||||
type="number"
|
||||
filled
|
||||
suffix="hrs"
|
||||
style="max-width: 200px"
|
||||
/>
|
||||
<q-input
|
||||
label="Number of Required Volunteers"
|
||||
v-model.number="modifiedTask.volunteers_required"
|
||||
type="number"
|
||||
filled
|
||||
style="max-width: 200px"
|
||||
/>
|
||||
<q-select
|
||||
label="Status of Task"
|
||||
v-model="modifiedTask.status"
|
||||
:options="TASKSTATUS"
|
||||
>
|
||||
</q-select>
|
||||
<div>
|
||||
<q-select
|
||||
label="Dependencies"
|
||||
hint="Add a list of tasks that need to be complete before this one"
|
||||
v-model="modifiedTask.depends_on"
|
||||
use-input
|
||||
multiple
|
||||
clearable
|
||||
emit-value
|
||||
map-options
|
||||
input-debounce="250"
|
||||
:options="tasks"
|
||||
option-label="title"
|
||||
option-value="$id"
|
||||
@filter="filterTasks"
|
||||
>
|
||||
</q-select>
|
||||
</div>
|
||||
<div>
|
||||
<q-select
|
||||
label="Boat"
|
||||
hint="Add a boat, if applicable"
|
||||
v-model="modifiedTask.boat"
|
||||
use-input
|
||||
clearable
|
||||
emit-value
|
||||
map-options
|
||||
input-debounce="250"
|
||||
:options="boatList"
|
||||
option-label="name"
|
||||
option-value="$id"
|
||||
>
|
||||
</q-select>
|
||||
</div>
|
||||
<div>
|
||||
<q-btn
|
||||
label="Submit"
|
||||
type="submit"
|
||||
color="primary"
|
||||
flat
|
||||
class="q-ml-sm"
|
||||
/>
|
||||
<q-btn
|
||||
label="Cancel"
|
||||
color="secondary"
|
||||
flat
|
||||
class="q-ml-sm"
|
||||
@click="$router.go(-1)"
|
||||
/>
|
||||
</div>
|
||||
</q-form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, Ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useTaskStore, TASKSTATUS } from 'src/stores/task';
|
||||
import type { TaskTag, SkillTag, Task } from 'src/stores/task';
|
||||
import { date } from 'quasar';
|
||||
import { useBoatStore } from 'src/stores/boat';
|
||||
|
||||
const props = defineProps<{ taskId?: string }>();
|
||||
const taskStore = useTaskStore();
|
||||
|
||||
const defaultTask = <Task>{
|
||||
description: '',
|
||||
due_date: date.formatDate(Date.now(), 'YYYY-MM-DD'),
|
||||
required_skills: [],
|
||||
title: '',
|
||||
tags: [],
|
||||
duration: 0,
|
||||
volunteers: [],
|
||||
volunteers_required: 0,
|
||||
status: 'ready',
|
||||
depends_on: [],
|
||||
};
|
||||
|
||||
taskStore.fetchTasks();
|
||||
|
||||
const { taskId } = props;
|
||||
const targetTask = taskId && taskStore.tasks.find((t) => t.$id === taskId);
|
||||
const modifiedTask = reactive(targetTask ? targetTask : defaultTask);
|
||||
|
||||
let tasks = taskStore.tasks;
|
||||
const boatList = useBoatStore().boats;
|
||||
|
||||
const skillTagOptions = ref<SkillTag[]>(taskStore.skillTags);
|
||||
const taskTagOptions = ref<TaskTag[]>(taskStore.taskTags);
|
||||
|
||||
const filterSkillTags = computed(
|
||||
() =>
|
||||
(val: string, update: (cb: () => void) => void): void => {
|
||||
return filterTags(skillTagOptions, taskStore.skillTags, val, update);
|
||||
}
|
||||
);
|
||||
const filterTaskTags = computed(
|
||||
() =>
|
||||
(val: string, update: (cb: () => void) => void): void => {
|
||||
return filterTags(taskTagOptions, taskStore.taskTags, val, update);
|
||||
}
|
||||
);
|
||||
const filterTasks = computed(
|
||||
() =>
|
||||
(val: string, update: (cb: () => void) => void): void => {
|
||||
if (val === '') {
|
||||
update(() => {
|
||||
tasks = taskStore.tasks;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
update(() => {
|
||||
tasks = taskStore.filterTasksByTitle(val);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
function filterTags(
|
||||
optionVar: Ref<(SkillTag | TaskTag)[] | undefined>,
|
||||
optionSrc: SkillTag[] | TaskTag[],
|
||||
val: string,
|
||||
update: (cb: () => void) => void
|
||||
): void {
|
||||
if (val === '') {
|
||||
update(() => {
|
||||
optionVar.value = optionSrc;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
update(() => {
|
||||
optionVar.value = optionSrc.filter((v) =>
|
||||
v.name.toLowerCase().includes(val.toLowerCase())
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Method to update the model in ISO 8601 format
|
||||
const updateDateISO = (value: string) => {
|
||||
modifiedTask.due_date = date.formatDate(value, 'YYYY-MM-DD');
|
||||
};
|
||||
const dateRule = (val: string) => {
|
||||
// Check if the date is valid using Quasar's date utils if needed
|
||||
// For simplicity, we are directly checking the date string validity
|
||||
return (val && !isNaN(Date.parse(val))) || 'Please enter a valid date';
|
||||
};
|
||||
const router = useRouter();
|
||||
|
||||
async function onSubmit() {
|
||||
try {
|
||||
if (modifiedTask.$id) {
|
||||
await taskStore.updateTask(modifiedTask);
|
||||
} else {
|
||||
await taskStore.addTask(modifiedTask);
|
||||
}
|
||||
router.go(-1);
|
||||
} catch (error) {
|
||||
console.error('Failed to create new Task: ', error);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
16
src/components/task/TaskListComponent.vue
Normal file
16
src/components/task/TaskListComponent.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<div class="q-pa-md" style="max-width: 350px">
|
||||
<q-list>
|
||||
<div v-for="task in tasks" :key="task.id">
|
||||
<TaskCardComponent :task="task" />
|
||||
</div>
|
||||
</q-list>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Task } from 'src/stores/task';
|
||||
import TaskCardComponent from './TaskCardComponent.vue';
|
||||
|
||||
defineProps<{ tasks: Task[] }>();
|
||||
</script>
|
||||
367
src/components/task/TaskTableComponent.vue
Normal file
367
src/components/task/TaskTableComponent.vue
Normal file
@@ -0,0 +1,367 @@
|
||||
<template>
|
||||
<div class="q-pa-sm">
|
||||
<q-table
|
||||
:rows="tasks"
|
||||
:columns="columns"
|
||||
:grid="$q.screen.xs"
|
||||
dense
|
||||
row-key="$id"
|
||||
flatten
|
||||
no-data-label="I didn't find anything for you"
|
||||
no-results-label="The filter didn't uncover any results"
|
||||
selection="multiple"
|
||||
v-model:selected="selected"
|
||||
:filter="searchFilter"
|
||||
:filter-method="filterRows"
|
||||
>
|
||||
<template v-slot:top>
|
||||
<q-select
|
||||
style="width: 250px"
|
||||
multiple
|
||||
use-chips
|
||||
clearable
|
||||
label="Skills Filter"
|
||||
input-debounce="250"
|
||||
:options="skillTagOptions"
|
||||
v-model="searchFilter.skillTags"
|
||||
option-label="name"
|
||||
option-value="$id"
|
||||
>
|
||||
</q-select>
|
||||
<q-select
|
||||
style="width: 250px"
|
||||
multiple
|
||||
use-chips
|
||||
clearable
|
||||
label="Tag Filter"
|
||||
input-debounce="250"
|
||||
:options="taskTagOptions"
|
||||
v-model="searchFilter.taskTags"
|
||||
option-label="name"
|
||||
option-value="$id"
|
||||
>
|
||||
<template v-slot: prepend>
|
||||
<q-icon name="wrench"></q-icon>
|
||||
</template>
|
||||
</q-select>
|
||||
<q-space />
|
||||
<q-input
|
||||
flatten
|
||||
debounce="300"
|
||||
color="primary"
|
||||
clearable
|
||||
v-model="searchFilter.title"
|
||||
>
|
||||
<template v-slot:append>
|
||||
<q-icon name="search" />
|
||||
</template>
|
||||
</q-input>
|
||||
</template>
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th key="desc" auto-width>
|
||||
<q-checkbox dense v-model="props.selected"></q-checkbox>
|
||||
</q-th>
|
||||
<q-th v-for="col in props.cols" :key="col.name" :props="props">
|
||||
{{ col.label }}
|
||||
</q-th>
|
||||
</q-tr>
|
||||
</template>
|
||||
|
||||
<template v-slot:body-cell-skills="props">
|
||||
<q-td :props="props" class="q-gutter-sm">
|
||||
<q-badge
|
||||
v-for="skill in props.value"
|
||||
:key="skill"
|
||||
:color="skill.tagColour"
|
||||
text-color="white"
|
||||
:label="skill.name"
|
||||
/>
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
<template v-slot:body-cell-tags="props">
|
||||
<q-td :props="props" class="q-gutter-sm">
|
||||
<q-badge
|
||||
v-for="tag in props.value"
|
||||
:key="tag"
|
||||
:color="tag.colour"
|
||||
text-color="white"
|
||||
:label="tag.name"
|
||||
/>
|
||||
</q-td>
|
||||
</template>
|
||||
<template v-slot:body-cell-actions="props">
|
||||
<q-td :props="props" class="q-gutter-sm">
|
||||
<q-btn
|
||||
label="Sign Up"
|
||||
size="sm"
|
||||
color="primary"
|
||||
:to="{ name: 'signup-task', params: { id: props.value } }"
|
||||
/>
|
||||
<q-btn
|
||||
label="Edit"
|
||||
size="sm"
|
||||
color="primary"
|
||||
:to="{ name: 'edit-task', params: { id: props.value } }"
|
||||
/>
|
||||
</q-td>
|
||||
</template>
|
||||
<template v-slot:item="props">
|
||||
<div
|
||||
class="q-pa-xs col-xs-12 col-sm-6 col-md-4 col-lg-3 grid-style-transition"
|
||||
:style="props.selected ? 'transform: scale(0.95);' : ''"
|
||||
>
|
||||
<q-card
|
||||
bordered
|
||||
flat
|
||||
:class="
|
||||
props.selected
|
||||
? $q.dark.isActive
|
||||
? 'bg-grey-9'
|
||||
: 'bg-grey-2'
|
||||
: ''
|
||||
"
|
||||
>
|
||||
<q-card-section>
|
||||
<q-checkbox
|
||||
dense
|
||||
v-model="props.selected"
|
||||
:label="props.row.name"
|
||||
/>
|
||||
</q-card-section>
|
||||
<q-separator />
|
||||
<q-list dense>
|
||||
<q-item
|
||||
v-for="col in props.cols.filter((col:Boat) => col.name !== 'desc')"
|
||||
:key="col.name"
|
||||
>
|
||||
<q-item-section>
|
||||
<q-item-label>{{ col.label }}</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<q-item-label caption v-if="col.name === 'skills'">
|
||||
<q-chip
|
||||
size="sm"
|
||||
v-for="skill in col.value"
|
||||
outline
|
||||
color="primary"
|
||||
:key="skill.$id"
|
||||
>{{ skill.name }}</q-chip
|
||||
></q-item-label
|
||||
>
|
||||
<q-item-label caption v-else-if="col.name === 'tags'">
|
||||
<q-chip
|
||||
size="sm"
|
||||
v-for="tag in col.value"
|
||||
outline
|
||||
color="primary"
|
||||
:key="tag.$id"
|
||||
>{{ tag.name }}</q-chip
|
||||
></q-item-label
|
||||
>
|
||||
<q-item-label caption v-else-if="col.name === 'actions'">
|
||||
<q-btn
|
||||
label="Sign Up"
|
||||
size="sm"
|
||||
color="primary"
|
||||
:to="{ name: 'signup-task', params: { id: col.value } }"
|
||||
/>
|
||||
<q-btn
|
||||
label="Edit"
|
||||
size="sm"
|
||||
color="primary"
|
||||
:to="{ name: 'edit-task', params: { id: col.value } }"
|
||||
/>
|
||||
</q-item-label>
|
||||
<q-item-label v-else caption>{{ col.value }}</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-card>
|
||||
</div>
|
||||
</template>
|
||||
</q-table>
|
||||
<q-page-sticky position="bottom-right" :offset="[18, 18]">
|
||||
<q-fab
|
||||
v-model="fabShow"
|
||||
vertical-actions-align="right"
|
||||
color="primary"
|
||||
glossy
|
||||
icon="keyboard_arrow_up"
|
||||
direction="up"
|
||||
>
|
||||
<q-fab-action
|
||||
color="primary"
|
||||
:disable="loading"
|
||||
label="New Task"
|
||||
to="/task/edit"
|
||||
icon="add"
|
||||
/>
|
||||
<q-fab-action
|
||||
v-if="tasks.length !== 0"
|
||||
class="q-ml-sm"
|
||||
color="primary"
|
||||
:disable="loading"
|
||||
label="Delete task(s)"
|
||||
@click="deleteTasks"
|
||||
icon="delete"
|
||||
/> </q-fab
|
||||
></q-page-sticky>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, defineProps, ref } from 'vue';
|
||||
import { useTaskStore, Task, SkillTag, TaskTag } from 'src/stores/task';
|
||||
import { QTableProps, date, useQuasar } from 'quasar';
|
||||
import { Boat, useBoatStore } from 'src/stores/boat';
|
||||
|
||||
const selected = ref([]);
|
||||
const loading = ref(false); // Placeholder
|
||||
const fabShow = ref(false);
|
||||
const columns = <QTableProps['columns']>[
|
||||
{
|
||||
name: 'title',
|
||||
required: true,
|
||||
label: 'Title',
|
||||
align: 'left',
|
||||
field: 'title',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
name: 'due_date',
|
||||
align: 'left',
|
||||
label: 'Due Date',
|
||||
field: 'due_date',
|
||||
format: (val) => date.formatDate(val, 'MMM DD, YYYY'),
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
align: 'left',
|
||||
label: 'Status',
|
||||
field: 'status',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
name: 'skills',
|
||||
align: 'left',
|
||||
label: 'Skills',
|
||||
field: (row) =>
|
||||
row.required_skills.map((s: string) => taskStore.getSkillById(s)),
|
||||
sortable: false,
|
||||
},
|
||||
{
|
||||
name: 'tags',
|
||||
align: 'left',
|
||||
label: 'Tags',
|
||||
field: (row) => row.tags.map((s: string) => taskStore.getTaskTagById(s)),
|
||||
sortable: false,
|
||||
},
|
||||
{
|
||||
name: 'boat',
|
||||
align: 'left',
|
||||
label: 'Boat',
|
||||
field: (row) =>
|
||||
useBoatStore().boats.find((boat) => boat.$id === row.boat)?.name,
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
name: 'volunteers',
|
||||
align: 'left',
|
||||
label: "People Req'd",
|
||||
field: 'volunteers_required',
|
||||
sortable: false,
|
||||
},
|
||||
{
|
||||
name: 'signedup',
|
||||
align: 'left',
|
||||
label: 'Signed Up',
|
||||
field: (row) => row.volunteers.length,
|
||||
sortable: false,
|
||||
},
|
||||
{
|
||||
name: 'depends',
|
||||
align: 'left',
|
||||
label: 'Dependent Tasks',
|
||||
field: 'depends_on',
|
||||
format: (val) => {
|
||||
return (
|
||||
val
|
||||
.map((t: string) => taskStore.getTaskById(t))
|
||||
.filter((t: Task) => t)
|
||||
.map((t: Task) => t.title)
|
||||
.join(', ') || null
|
||||
);
|
||||
},
|
||||
},
|
||||
{ name: 'actions', align: 'center', label: 'Actions', field: '$id' },
|
||||
];
|
||||
|
||||
defineProps<{ tasks: Task[] }>();
|
||||
const taskStore = useTaskStore();
|
||||
const $q = useQuasar();
|
||||
|
||||
interface SearchObject {
|
||||
title: string;
|
||||
skillTags: SkillTag[];
|
||||
taskTags: TaskTag[];
|
||||
}
|
||||
|
||||
const searchFilter = ref<SearchObject>({
|
||||
title: '',
|
||||
skillTags: [],
|
||||
taskTags: [],
|
||||
});
|
||||
|
||||
const skillTagOptions = ref<SkillTag[]>(taskStore.skillTags);
|
||||
const taskTagOptions = ref<TaskTag[]>(taskStore.taskTags);
|
||||
|
||||
// function onRowClick(evt: Event, row: Task) {
|
||||
// router.push({ name: 'edit-task', params: { id: row.$id } });
|
||||
// }
|
||||
// TODO: Implement server side search
|
||||
const filterRows = computed(
|
||||
() => (rows: readonly Task[], terms: SearchObject) => {
|
||||
return rows
|
||||
.filter((row) =>
|
||||
terms.title
|
||||
? row.title.toLowerCase().includes(terms.title.toLowerCase())
|
||||
: true
|
||||
)
|
||||
.filter((row) =>
|
||||
terms.skillTags && terms.skillTags.length > 0
|
||||
? row.required_skills.some((req_skill) =>
|
||||
terms.skillTags.map((t) => t.$id).includes(req_skill)
|
||||
)
|
||||
: true
|
||||
)
|
||||
.filter((row) =>
|
||||
terms.taskTags && terms.taskTags.length > 0
|
||||
? row.tags.some((tag) =>
|
||||
terms.taskTags.map((t) => t.$id).includes(tag)
|
||||
)
|
||||
: true
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
function deleteTasks() {
|
||||
confirmDelete(selected.value);
|
||||
}
|
||||
function confirmDelete(tasks: Task[]) {
|
||||
$q.dialog({
|
||||
title: 'Confirm',
|
||||
message:
|
||||
'You are about to delete ' + tasks.length + ' tasks. Are you sure?',
|
||||
cancel: true,
|
||||
persistent: true,
|
||||
}).onOk(() => {
|
||||
selected.value.map((task: Task) => {
|
||||
taskStore.deleteTask(task);
|
||||
return;
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@@ -11,5 +11,7 @@ import { ref } from 'vue';
|
||||
import { useBoatStore } from 'src/stores/boat';
|
||||
import ToolbarComponent from 'src/components/ToolbarComponent.vue';
|
||||
|
||||
const boatStore = useBoatStore();
|
||||
boatStore.fetchBoats();
|
||||
const boats = ref(useBoatStore().boats);
|
||||
</script>
|
||||
|
||||
@@ -8,32 +8,25 @@
|
||||
<q-avatar icon="person" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
Ricky Gervais
|
||||
{{ authStore.currentUser?.name }}
|
||||
<q-item-label caption>Name</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item>
|
||||
<q-item-section avatar>
|
||||
<q-avatar icon="numbers" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
12345
|
||||
<q-item-label caption>Member ID</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-separator />
|
||||
<q-item>
|
||||
<q-item-section>
|
||||
<q-item-label overline>Certifications</q-item-label>
|
||||
<q-chip square icon="verified" color="primary" text-color="white"
|
||||
>J/27</q-chip
|
||||
>
|
||||
<q-chip square icon="verified" color="green" text-color="white"
|
||||
>Capri25</q-chip
|
||||
>
|
||||
<q-chip square icon="verified" color="grey-8" text-color="white"
|
||||
>Night</q-chip
|
||||
>
|
||||
<div>
|
||||
<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="grey-9" text-color="white"
|
||||
>Night</q-chip
|
||||
>
|
||||
</div>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
@@ -42,4 +35,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import ToolbarComponent from 'src/components/ToolbarComponent.vue';
|
||||
import { useAuthStore } from 'src/stores/auth';
|
||||
|
||||
const authStore = useAuthStore();
|
||||
</script>
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
<template>
|
||||
<toolbar-component pageTitle="Tasks" />
|
||||
<q-page padding>
|
||||
<q-card bordered separator class="mobile-card">
|
||||
<q-card-section clickable v-ripple>
|
||||
<div class="text-h6">Launch Prep</div>
|
||||
<div class="text-subtitle2">Prepare for Launch</div>
|
||||
<q-chip size="md" color="green" text-color="white" icon="alarm">
|
||||
APR 1,2024
|
||||
</q-chip>
|
||||
<q-chip size="md" icon="build"> 24 tasks </q-chip>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
<q-card bordered separator class="mobile-card">
|
||||
<q-card-section clickable v-ripple>
|
||||
<div class="text-h6">General Maintenance</div>
|
||||
<div class="text-subtitle2">Day to day maintenance and upkeep</div>
|
||||
<q-chip size="md" icon="build"> 4 tasks </q-chip>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ToolbarComponent from 'src/components/ToolbarComponent.vue';
|
||||
</script>
|
||||
8
src/pages/admin/TaskAdminPage.vue
Normal file
8
src/pages/admin/TaskAdminPage.vue
Normal file
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<q-page padding>
|
||||
<!-- content -->
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
@@ -1,38 +1,36 @@
|
||||
<template>
|
||||
<q-page padding>
|
||||
<q-page>
|
||||
<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-form @submit="onSubmit" @reset="onReset" class="q-gutter-sm">
|
||||
<q-item>
|
||||
<q-item-section :avatar="true">
|
||||
<q-icon name="person"
|
||||
/></q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label> Name: {{ bookingForm.name }} </q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-expansion-item
|
||||
expand-separator
|
||||
v-model="resourceView"
|
||||
icon="calendar_month"
|
||||
label="Boat and Time"
|
||||
default-opened
|
||||
class="q-mt-none"
|
||||
:caption="bookingSummary"
|
||||
>
|
||||
<q-separator />
|
||||
<resource-schedule-viewer-component
|
||||
@on-click-time="onClickTime"
|
||||
@on-update-duration="
|
||||
(value) => {
|
||||
bookingForm.duration = value;
|
||||
}
|
||||
"
|
||||
/>
|
||||
<q-banner :class="$q.dark.isActive ? 'bg-grey-9' : 'bg-grey-3'">
|
||||
Use the calendar to pick a date. Select an available boat and
|
||||
timeslot below.
|
||||
</q-banner>
|
||||
<BoatScheduleTableComponent v-model="timeblock" />
|
||||
|
||||
<q-banner
|
||||
rounded
|
||||
class="bg-warning text-grey-10"
|
||||
v-if="bookingForm.boat?.defects"
|
||||
style="max-width: 95vw; margin: auto"
|
||||
v-if="bookingForm.boat && bookingForm.boat.defects.length > 0"
|
||||
>
|
||||
<template v-slot:avatar>
|
||||
<q-icon name="warning" color="grey-10" />
|
||||
@@ -62,7 +60,32 @@
|
||||
icon="people"
|
||||
label="Crew and Passengers"
|
||||
default-opened
|
||||
>
|
||||
><q-banner v-if="bookingForm.boat"
|
||||
>Passengers:
|
||||
{{ bookingForm.members.length + bookingForm.guests.length }} /
|
||||
{{ bookingForm.boat.maxPassengers }}</q-banner
|
||||
>
|
||||
<q-item
|
||||
class="q-my-sm"
|
||||
v-for="passenger in [...bookingForm.members, ...bookingForm.guests]"
|
||||
:key="passenger.name"
|
||||
>
|
||||
<q-item-section avatar>
|
||||
<q-avatar color="primary" text-color="white" size="sm">
|
||||
{{
|
||||
passenger.name
|
||||
.split(' ')
|
||||
.map((i) => i.charAt(0))
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
}}
|
||||
</q-avatar>
|
||||
</q-item-section>
|
||||
<q-item-section>{{ passenger.name }}</q-item-section>
|
||||
<q-item-section side>
|
||||
<q-btn color="negative" flat dense round icon="cancel" />
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-separator />
|
||||
</q-expansion-item>
|
||||
|
||||
@@ -74,50 +97,51 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, computed, watch } from 'vue';
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { useAuthStore } from 'src/stores/auth';
|
||||
import { Boat, useBoatStore } from 'src/stores/boat';
|
||||
import { Dialog, date } from 'quasar';
|
||||
import ResourceScheduleViewerComponent from 'src/components/ResourceScheduleViewerComponent.vue';
|
||||
import { makeDateTime } from '@quasar/quasar-ui-qcalendar';
|
||||
import { useScheduleStore, Reservation } from 'src/stores/schedule';
|
||||
import { date } from 'quasar';
|
||||
import { Interval } from 'src/stores/schedule.types';
|
||||
import BoatScheduleTableComponent from 'src/components/scheduling/boat/BoatScheduleTableComponent.vue';
|
||||
import { getNewId } from 'src/utils/misc';
|
||||
|
||||
interface BookingForm {
|
||||
bookingId: string;
|
||||
name?: string;
|
||||
boat?: Boat;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
members: { name: string }[];
|
||||
guests: { name: string }[];
|
||||
}
|
||||
|
||||
const auth = useAuthStore();
|
||||
const dateFormat = 'ddd MMM D, YYYY h:mm A';
|
||||
const dateFormat = 'MMM D, YYYY h:mm A';
|
||||
const resourceView = ref(true);
|
||||
const scheduleStore = useScheduleStore();
|
||||
const bookingForm = reactive({
|
||||
bookingId: scheduleStore.getNewId(),
|
||||
const timeblock = ref<Interval>();
|
||||
const bookingForm = ref<BookingForm>({
|
||||
bookingId: 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,
|
||||
endDate: date.formatDate(new Date(), dateFormat),
|
||||
members: [{ name: 'Karen Henrikso' }, { name: "Rich O'hare" }],
|
||||
guests: [{ name: 'Bob Barker' }, { name: 'Taylor Swift' }],
|
||||
});
|
||||
|
||||
watch(bookingForm, (b, a) => {
|
||||
const newRes = <Reservation>{
|
||||
id: b.bookingId,
|
||||
user: b.name,
|
||||
resource: b.boat,
|
||||
start: date.extractDate(b.startDate, dateFormat),
|
||||
end: date.extractDate(b.endDate, dateFormat),
|
||||
reservationDate: new Date(),
|
||||
status: 'tentative',
|
||||
};
|
||||
//TODO: Turn this into a validator.
|
||||
scheduleStore.isOverlapped(newRes)
|
||||
? Dialog.create({ message: 'This booking overlaps another!' })
|
||||
: scheduleStore.addOrCreateReservation(newRes);
|
||||
watch(timeblock, (tb_new) => {
|
||||
bookingForm.value.boat = useBoatStore().boats.find(
|
||||
(b) => b.$id === tb_new?.boatId
|
||||
);
|
||||
bookingForm.value.startDate = date.formatDate(tb_new?.start, dateFormat);
|
||||
bookingForm.value.endDate = date.formatDate(tb_new?.end, dateFormat);
|
||||
});
|
||||
|
||||
// //TODO: Turn this into a validator.
|
||||
// scheduleStore.isReservationOverlapped(newRes)
|
||||
// ? Dialog.create({ message: 'This booking overlaps another!' })
|
||||
// : scheduleStore.addOrCreateReservation(newRes);
|
||||
|
||||
const onReset = () => {
|
||||
// TODO
|
||||
};
|
||||
@@ -126,38 +150,27 @@ 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' : '');
|
||||
if (bookingForm.value.endDate && bookingForm.value.startDate) {
|
||||
const diff = date.getDateDiff(
|
||||
bookingForm.value.endDate,
|
||||
bookingForm.value.startDate,
|
||||
'minutes'
|
||||
);
|
||||
return diff <= 0
|
||||
? 'Invalid'
|
||||
: (diff > 60 ? Math.trunc(diff / 60) + ' hours' : '') +
|
||||
(diff % 60 > 0 ? ' ' + (diff % 60) + ' minutes' : '');
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
const bookingSummary = computed(() => {
|
||||
return bookingForm.boat && bookingForm.startDate && bookingForm.endDate
|
||||
? `${bookingForm.boat.name} @ ${bookingForm.startDate} for ${bookingDuration.value}`
|
||||
return bookingForm.value.boat &&
|
||||
bookingForm.value.startDate &&
|
||||
bookingForm.value.endDate
|
||||
? `${bookingForm.value.boat.name} @ ${bookingForm.value.startDate} for ${bookingDuration.value}`
|
||||
: '';
|
||||
});
|
||||
|
||||
const limitDate = (startDate: string) => {
|
||||
return date.isBetweenDates(
|
||||
startDate,
|
||||
new Date(),
|
||||
date.addToDate(new Date(), { days: 21 }),
|
||||
{ inclusiveFrom: true, inclusiveTo: true, onlyDate: true }
|
||||
);
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -1,12 +1,137 @@
|
||||
<template>
|
||||
<q-page padding>
|
||||
<!-- content -->
|
||||
<div class="subcontent">
|
||||
<!-- <navigation-bar @today="onToday" @prev="onPrev" @next="onNext" /> -->
|
||||
|
||||
<div class="row justify-center">
|
||||
<q-calendar-day
|
||||
ref="calendar"
|
||||
v-model="selectedDate"
|
||||
view="day"
|
||||
:max-days="3"
|
||||
bordered
|
||||
animated
|
||||
transition-next="slide-left"
|
||||
transition-prev="slide-right"
|
||||
@change="onChange"
|
||||
@moved="onMoved"
|
||||
@click-date="onClickDate"
|
||||
@click-time="onClickTime"
|
||||
@click-interval="onClickInterval"
|
||||
@click-head-day="onClickHeadDay"
|
||||
>
|
||||
<template
|
||||
#day-body="{
|
||||
scope: { timestamp, timeStartPos, timeDurationHeight },
|
||||
}"
|
||||
>
|
||||
<template
|
||||
v-for="event in reservationEvents(timestamp)"
|
||||
:key="event.id"
|
||||
>
|
||||
<div
|
||||
v-if="event.start !== undefined"
|
||||
class="booking-event"
|
||||
:style="slotStyle(event, timeStartPos, timeDurationHeight)"
|
||||
>
|
||||
<span class="title q-calendar__ellipsis">
|
||||
{{ event.user }}
|
||||
<q-tooltip>{{
|
||||
event.start +
|
||||
' - ' +
|
||||
boatStore.getBoatById(event.resource)?.name
|
||||
}}</q-tooltip>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</q-calendar-day>
|
||||
</div>
|
||||
</div>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useScheduleStore } from 'src/stores/schedule';
|
||||
import { useReservationStore } from 'src/stores/reservation';
|
||||
import { Reservation } from 'src/stores/schedule.types';
|
||||
import { ref } from 'vue';
|
||||
const reservationStore = useReservationStore();
|
||||
import {
|
||||
TimestampOrNull,
|
||||
getDate,
|
||||
parsed,
|
||||
today,
|
||||
} from '@quasar/quasar-ui-qcalendar';
|
||||
import { QCalendarDay } from '@quasar/quasar-ui-qcalendar';
|
||||
import { date } from 'quasar';
|
||||
import { Timestamp } from '@quasar/quasar-ui-qcalendar';
|
||||
import { useBoatStore } from 'src/stores/boat';
|
||||
|
||||
const scheduleStore = useScheduleStore();
|
||||
scheduleStore.loadSampleData();
|
||||
const selectedDate = ref(today());
|
||||
const boatStore = useBoatStore();
|
||||
|
||||
// Method declarations
|
||||
|
||||
function slotStyle(
|
||||
event: Reservation,
|
||||
timeStartPos: (time: TimestampOrNull) => string,
|
||||
timeDurationHeight: (minutes: number) => string
|
||||
) {
|
||||
const s = {
|
||||
top: '',
|
||||
height: '',
|
||||
'align-items': 'flex-start',
|
||||
};
|
||||
if (timeStartPos && timeDurationHeight) {
|
||||
s.top = timeStartPos(parsed(event.start)) + 'px';
|
||||
s.height =
|
||||
timeDurationHeight(date.getDateDiff(event.end, event.start, 'minutes')) +
|
||||
'px';
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function reservationEvents(timestamp: Timestamp) {
|
||||
return reservationStore.getReservationsByDate(getDate(timestamp));
|
||||
}
|
||||
function onMoved(data: Event) {
|
||||
console.log('onMoved', data);
|
||||
}
|
||||
function onChange(data: Event) {
|
||||
console.log('onChange', data);
|
||||
}
|
||||
function onClickDate(data: Event) {
|
||||
console.log('onClickDate', data);
|
||||
}
|
||||
function onClickTime(data: Event) {
|
||||
console.log('onClickTime', data);
|
||||
}
|
||||
function onClickInterval(data: Event) {
|
||||
console.log('onClickInterval', data);
|
||||
}
|
||||
function onClickHeadDay(data: Event) {
|
||||
console.log('onClickHeadDay', data);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="sass" scoped>
|
||||
|
||||
.booking-event
|
||||
position: absolute
|
||||
font-size: 12px
|
||||
justify-content: space-evenly
|
||||
margin: 0 1px
|
||||
text-overflow: ellipsis
|
||||
overflow: hidden
|
||||
color: white
|
||||
max-width: 100%
|
||||
background: #027BE3FF
|
||||
cursor: pointer
|
||||
|
||||
.title
|
||||
position: relative
|
||||
display: flex
|
||||
justify-content: center
|
||||
align-items: center
|
||||
height: 100%
|
||||
</style>
|
||||
|
||||
315
src/pages/schedule/ManageCalendar.vue
Normal file
315
src/pages/schedule/ManageCalendar.vue
Normal file
@@ -0,0 +1,315 @@
|
||||
<template>
|
||||
<div class="fit row wrap justify-start items-start content-start">
|
||||
<div class="q-pa-md">
|
||||
<div class="scheduler" style="max-width: 1200px">
|
||||
<NavigationBar @next="onNext" @today="onToday" @prev="onPrev" />
|
||||
<q-calendar-scheduler
|
||||
ref="calendar"
|
||||
v-model="selectedDate"
|
||||
v-model:model-resources="boats"
|
||||
resource-key="$id"
|
||||
resource-label="name"
|
||||
view="week"
|
||||
:weekdays="[1, 2, 3, 4, 5, 6, 0]"
|
||||
animated
|
||||
bordered
|
||||
:drag-enter-func="onDragEnter"
|
||||
:drag-over-func="onDragOver"
|
||||
:drag-leave-func="onDragLeave"
|
||||
:drop-func="onDrop"
|
||||
:day-min-height="50"
|
||||
:cell-width="150"
|
||||
:day-height="0"
|
||||
>
|
||||
<template #day="{ scope }">
|
||||
<div
|
||||
v-if="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 timeblockTemplates"
|
||||
:key="template.$id"
|
||||
:model-value="template"
|
||||
/>
|
||||
</q-list>
|
||||
</div>
|
||||
</div>
|
||||
<q-dialog v-model="alert">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="text-h6">Warning!</div>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pt-none">
|
||||
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 {
|
||||
blocksOverlapped,
|
||||
buildInterval,
|
||||
useScheduleStore,
|
||||
} from 'src/stores/schedule';
|
||||
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';
|
||||
|
||||
const selectedDate = ref(today());
|
||||
const { fetchBoats } = useBoatStore();
|
||||
const scheduleStore = useScheduleStore();
|
||||
const { boats } = storeToRefs(useBoatStore());
|
||||
const { timeblockTemplates } = storeToRefs(useScheduleStore());
|
||||
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 scheduleStore.fetchIntervals();
|
||||
await scheduleStore.fetchIntervalTemplates();
|
||||
});
|
||||
|
||||
const filteredIntervals = (date: Timestamp, boat: Boat) => {
|
||||
return scheduleStore.getIntervals(date, boat).value;
|
||||
};
|
||||
|
||||
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 = timeBlocksFromTemplate(boat, templateId, date);
|
||||
intervals.forEach((interval) => scheduleStore.createInterval(interval));
|
||||
}
|
||||
|
||||
function getIntervals(date: Timestamp, boat: Boat) {
|
||||
return scheduleStore.getIntervals(date, boat);
|
||||
}
|
||||
|
||||
function timeBlocksFromTemplate(
|
||||
boat: Boat,
|
||||
templateId: string,
|
||||
date: string
|
||||
): Interval[] {
|
||||
const template = timeblockTemplates.value.find((t) => t.$id === templateId);
|
||||
return template
|
||||
? template.timeTuples.map((timeTuple: TimeTuple) =>
|
||||
buildInterval(boat, timeTuple, date)
|
||||
)
|
||||
: [];
|
||||
}
|
||||
|
||||
function deleteBlock(block: Interval) {
|
||||
if (block.$id) {
|
||||
scheduleStore.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.contains('q-calendar-scheduler__head--day') ||
|
||||
e.target.classList.contains('q-calendar-scheduler__day'))
|
||||
)
|
||||
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.contains('q-calendar-scheduler__head--day') ||
|
||||
e.target.classList.contains('q-calendar-scheduler__day'))
|
||||
)
|
||||
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).value;
|
||||
const boatsToApply = type === 'head-day' ? boats.value : [resource];
|
||||
overlapped.value = boatsToApply
|
||||
.map((boat) =>
|
||||
blocksOverlapped(
|
||||
existingIntervals.concat(
|
||||
timeBlocksFromTemplate(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>
|
||||
@@ -3,7 +3,7 @@
|
||||
<q-item v-for="link in navlinks" :key="link.label">
|
||||
<q-btn
|
||||
:icon="link.icon"
|
||||
color="primary"
|
||||
:color="link.color"
|
||||
size="1.25em"
|
||||
:to="link.to"
|
||||
:label="link.label"
|
||||
@@ -21,7 +21,19 @@ const navlinks = [
|
||||
icon: 'more_time',
|
||||
to: '/schedule/book',
|
||||
label: 'Create a Reservation',
|
||||
color: 'primary',
|
||||
},
|
||||
{
|
||||
icon: 'calendar_month',
|
||||
to: '/schedule/view',
|
||||
label: 'View Schedule',
|
||||
color: 'primary',
|
||||
},
|
||||
{
|
||||
icon: 'edit_calendar',
|
||||
to: '/schedule/manage',
|
||||
label: 'Manage Calendar',
|
||||
color: 'accent',
|
||||
},
|
||||
{ icon: 'calendar_month', to: '/schedule/view', label: 'View Schedule' },
|
||||
];
|
||||
</script>
|
||||
|
||||
16
src/pages/task/TaskEditPage.vue
Normal file
16
src/pages/task/TaskEditPage.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<ToolbarComponent pageTitle="Tasks" />
|
||||
<q-page padding>
|
||||
<div class="q-pa-md" style="max-width: 400px">
|
||||
<TaskEditComponent :taskId="taskId" />
|
||||
</div>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const taskId = useRoute().params.id as string;
|
||||
console.log(taskId);
|
||||
import ToolbarComponent from 'src/components/ToolbarComponent.vue';
|
||||
import TaskEditComponent from 'src/components/task/TaskEditComponent.vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
</script>
|
||||
16
src/pages/task/TaskPage.vue
Normal file
16
src/pages/task/TaskPage.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<toolbar-component pageTitle="Tasks" />
|
||||
<q-page padding>
|
||||
<TaskTableComponent :tasks="taskStore.tasks" />
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTaskStore } from 'stores/task';
|
||||
import ToolbarComponent from 'src/components/ToolbarComponent.vue';
|
||||
import TaskTableComponent from 'src/components/task/TaskTableComponent.vue';
|
||||
|
||||
const taskStore = useTaskStore();
|
||||
|
||||
taskStore.fetchTasks(); // Fetch on mount
|
||||
</script>
|
||||
@@ -38,9 +38,6 @@ export default route(function (/* { store, ssrContext } */) {
|
||||
Router.beforeEach((to) => {
|
||||
const auth = useAuthStore();
|
||||
|
||||
if (!auth.ready) {
|
||||
return false;
|
||||
}
|
||||
if (auth.currentUser) {
|
||||
return to.meta.accountRoute ? { name: 'index' } : true;
|
||||
} else {
|
||||
|
||||
@@ -1,24 +1,9 @@
|
||||
import ScheduleIndexPage from 'pages/schedule/ScheduleIndexPage.vue';
|
||||
import ChecklistPageVue from 'pages/ChecklistPage.vue';
|
||||
import LoginPageVue from 'pages/LoginPage.vue';
|
||||
import ReferencePageVue from 'src/pages/reference/ReferencePage.vue';
|
||||
import ReferenceIndexPageVue from 'src/pages/reference/ReferenceIndexPage.vue';
|
||||
import ReferenceItemPageVue from 'src/pages/reference/ReferenceItemPage.vue';
|
||||
import MainLayoutVue from 'src/layouts/MainLayout.vue';
|
||||
import BoatPageVue from 'src/pages/BoatPage.vue';
|
||||
import CertificationPageVue from 'src/pages/CertificationPage.vue';
|
||||
import IndexPageVue from 'src/pages/IndexPage.vue';
|
||||
import ProfilePageVue from 'src/pages/ProfilePage.vue';
|
||||
import TaskPageVue from 'src/pages/TaskPage.vue';
|
||||
import { RouteRecordRaw } from 'vue-router';
|
||||
import SchedulePageView from 'pages/schedule/SchedulePageView.vue';
|
||||
import BoatReservationPageVue from 'src/pages/schedule/BoatReservationPage.vue';
|
||||
import BoatScheduleViewVue from 'src/pages/schedule/BoatScheduleView.vue';
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
component: MainLayoutVue,
|
||||
component: () => import('src/layouts/MainLayout.vue'),
|
||||
// If we get so big we need lazy loading, we can use imports again
|
||||
// component: () => import('layouts/MainLayout.vue'),
|
||||
children: [
|
||||
@@ -26,69 +11,89 @@ const routes: RouteRecordRaw[] = [
|
||||
path: '',
|
||||
// If we get so big we need lazy loading, we can use imports again
|
||||
// component: () => import('pages/IndexPage.vue'),
|
||||
component: IndexPageVue,
|
||||
component: () => import('src/pages/IndexPage.vue'),
|
||||
name: 'index',
|
||||
},
|
||||
{
|
||||
path: '/boat',
|
||||
component: BoatPageVue,
|
||||
component: () => import('src/pages/BoatPage.vue'),
|
||||
name: 'boat',
|
||||
},
|
||||
{
|
||||
path: '/schedule',
|
||||
component: SchedulePageView,
|
||||
component: () => import('pages/schedule/SchedulePageView.vue'),
|
||||
name: 'schedule',
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
component: ScheduleIndexPage,
|
||||
component: () => import('pages/schedule/ScheduleIndexPage.vue'),
|
||||
name: 'schedule-index',
|
||||
},
|
||||
{
|
||||
path: 'book',
|
||||
component: BoatReservationPageVue,
|
||||
component: () =>
|
||||
import('src/pages/schedule/BoatReservationPage.vue'),
|
||||
name: 'reserve-boat',
|
||||
},
|
||||
{
|
||||
path: 'view',
|
||||
component: BoatScheduleViewVue,
|
||||
component: () => import('src/pages/schedule/BoatScheduleView.vue'),
|
||||
name: 'boat-schedule',
|
||||
},
|
||||
{
|
||||
path: 'manage',
|
||||
component: () => import('src/pages/schedule/ManageCalendar.vue'),
|
||||
name: 'manage-schedule',
|
||||
meta: { requiresScheduleAdmin: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/certification',
|
||||
component: CertificationPageVue,
|
||||
component: () => import('src/pages/CertificationPage.vue'),
|
||||
name: 'certification',
|
||||
},
|
||||
{
|
||||
path: '/task',
|
||||
component: TaskPageVue,
|
||||
name: 'task',
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
component: () => import('src/pages/task/TaskPage.vue'),
|
||||
name: 'task-index',
|
||||
},
|
||||
{
|
||||
path: '/:id/edit',
|
||||
component: () => import('pages/task/TaskEditPage.vue'),
|
||||
name: 'edit-task',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/checklist',
|
||||
component: ChecklistPageVue,
|
||||
component: () => import('pages/ChecklistPage.vue'),
|
||||
name: 'checklist',
|
||||
},
|
||||
{
|
||||
path: '/profile',
|
||||
component: ProfilePageVue,
|
||||
component: () => import('src/pages/ProfilePage.vue'),
|
||||
name: 'profile',
|
||||
},
|
||||
{
|
||||
path: '/reference',
|
||||
component: ReferencePageVue,
|
||||
component: () => import('src/pages/reference/ReferencePage.vue'),
|
||||
name: 'reference',
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
component: ReferenceIndexPageVue,
|
||||
component: () =>
|
||||
import('src/pages/reference/ReferenceIndexPage.vue'),
|
||||
name: 'reference-index',
|
||||
},
|
||||
{
|
||||
path: '/reference/:id/view',
|
||||
component: ReferenceItemPageVue,
|
||||
component: () =>
|
||||
import('src/pages/reference/ReferenceItemPage.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -97,6 +102,7 @@ const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/admin',
|
||||
component: () => import('layouts/AdminLayout.vue'),
|
||||
meta: { requiresAdmin: true },
|
||||
children: [
|
||||
{
|
||||
path: '/user',
|
||||
@@ -112,7 +118,7 @@ const routes: RouteRecordRaw[] = [
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
component: LoginPageVue,
|
||||
component: () => import('pages/LoginPage.vue'),
|
||||
name: 'login',
|
||||
meta: {
|
||||
publicRoute: true,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ID, account } from 'boot/appwrite';
|
||||
import type { Models } from 'appwrite';
|
||||
import { ID, account, functions } from 'boot/appwrite';
|
||||
import { ExecutionMethod, OAuthProvider, type Models } from 'appwrite';
|
||||
import { ref } from 'vue';
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const currentUser = ref<Models.User<Models.Preferences> | null>(null);
|
||||
const ready = ref(false);
|
||||
const userNames = ref<Record<string, string>>({});
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
@@ -13,7 +13,6 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
} catch {
|
||||
currentUser.value = null;
|
||||
}
|
||||
ready.value = true;
|
||||
}
|
||||
|
||||
async function register(email: string, password: string) {
|
||||
@@ -21,21 +20,55 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
return await login(email, password);
|
||||
}
|
||||
async function login(email: string, password: string) {
|
||||
await account.createEmailSession(email, password);
|
||||
await account.createEmailPasswordSession(email, password);
|
||||
currentUser.value = await account.get();
|
||||
}
|
||||
async function googleLogin() {
|
||||
account.createOAuth2Session(
|
||||
'google',
|
||||
OAuthProvider.Google,
|
||||
'https://bab.toal.ca/',
|
||||
'https://bab.toal.ca/#/login'
|
||||
);
|
||||
currentUser.value = await account.get();
|
||||
}
|
||||
|
||||
function getUserNameById(id: string) {
|
||||
try {
|
||||
if (!userNames.value[id]) {
|
||||
userNames.value[id] = '';
|
||||
functions
|
||||
.createExecution(
|
||||
'664038294b5473ef0c8d',
|
||||
'',
|
||||
false,
|
||||
'/userinfo/' + id,
|
||||
ExecutionMethod.GET
|
||||
)
|
||||
.then((res) => {
|
||||
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() {
|
||||
return account.deleteSession('current').then((currentUser.value = null));
|
||||
}
|
||||
|
||||
return { currentUser, register, login, googleLogin, logout, init, ready };
|
||||
return {
|
||||
currentUser,
|
||||
getUserNameById,
|
||||
register,
|
||||
login,
|
||||
googleLogin,
|
||||
logout,
|
||||
init,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
import { Models } from 'appwrite';
|
||||
import { defineStore } from 'pinia';
|
||||
import { AppwriteIds, databases } from 'src/boot/appwrite';
|
||||
import { ref } from 'vue';
|
||||
|
||||
// const boatSource = null;
|
||||
|
||||
export interface Boat {
|
||||
id: number;
|
||||
export interface Boat extends Models.Document {
|
||||
$id: string;
|
||||
name: string;
|
||||
displayName?: string;
|
||||
class?: string;
|
||||
year?: number;
|
||||
imgsrc?: string;
|
||||
iconsrc?: string;
|
||||
booking?: {
|
||||
available: boolean;
|
||||
requiredCerts: string[];
|
||||
maxDuration: number;
|
||||
maxPassengers: number;
|
||||
};
|
||||
defects?: {
|
||||
imgSrc?: string;
|
||||
iconSrc?: string;
|
||||
bookingAvailable: boolean;
|
||||
requiredCerts: string[];
|
||||
maxPassengers: number;
|
||||
defects: {
|
||||
type: string;
|
||||
severity: string;
|
||||
description: string;
|
||||
@@ -23,58 +24,24 @@ export interface Boat {
|
||||
}[];
|
||||
}
|
||||
|
||||
const getSampleData = () => [
|
||||
{
|
||||
id: 1,
|
||||
name: 'ProjectX',
|
||||
class: 'J/27',
|
||||
year: 1981,
|
||||
imgsrc: '/tmpimg/j27.png',
|
||||
iconsrc: '/tmpimg/projectx_avatar256.png',
|
||||
defects: [
|
||||
{
|
||||
type: 'engine',
|
||||
severity: 'moderate',
|
||||
description: 'Fuel line leaks at engine fitting.',
|
||||
detail: `The gasket in the end of the fuel hose is damaged, and does not properly seal.
|
||||
This will cause fuel to leak, and will allow air into the fuel chamber, causing a lean mixture,
|
||||
and rough engine performance.`,
|
||||
},
|
||||
{
|
||||
type: 'rigging',
|
||||
severity: 'moderate',
|
||||
description: 'Tiller extension is broken.',
|
||||
detail:
|
||||
'The tiller extension swivel is broken, and will not attach to the tiller.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Take5',
|
||||
class: 'J/27',
|
||||
year: 1985,
|
||||
imgsrc: '/tmpimg/j27.png',
|
||||
iconsrc: '/tmpimg/take5_avatar32.png',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'WeeBeestie',
|
||||
class: 'Capri 25',
|
||||
year: 1989,
|
||||
imgsrc: '/tmpimg/capri25.png',
|
||||
},
|
||||
];
|
||||
export const useBoatStore = defineStore('boat', () => {
|
||||
const boats = ref<Boat[]>([]);
|
||||
|
||||
export const useBoatStore = defineStore('boat', {
|
||||
state: () => ({
|
||||
boats: getSampleData(),
|
||||
}),
|
||||
async function fetchBoats() {
|
||||
try {
|
||||
const response = await databases.listDocuments(
|
||||
AppwriteIds.databaseId,
|
||||
AppwriteIds.collection.boat
|
||||
);
|
||||
boats.value = response.documents as Boat[];
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch boats', error);
|
||||
}
|
||||
}
|
||||
|
||||
getters: {},
|
||||
const getBoatById = (id: string): Boat | null => {
|
||||
return boats.value.find((b) => b.$id === id) || null;
|
||||
};
|
||||
|
||||
actions: {
|
||||
// update () {
|
||||
// }
|
||||
},
|
||||
return { boats, fetchBoats, getBoatById };
|
||||
});
|
||||
|
||||
131
src/stores/reservation.ts
Normal file
131
src/stores/reservation.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import type { Reservation } from './schedule.types';
|
||||
import { computed, ref } from 'vue';
|
||||
import { AppwriteIds, databases } from 'src/boot/appwrite';
|
||||
import { Query } from 'appwrite';
|
||||
import { date } from 'quasar';
|
||||
import { Timestamp, parseDate, today } from '@quasar/quasar-ui-qcalendar';
|
||||
|
||||
export const useReservationStore = defineStore('reservation', () => {
|
||||
type LoadingTypes = 'loaded' | 'pending' | undefined;
|
||||
const reservations = ref<Map<string, Reservation>>(new Map());
|
||||
const datesLoaded = ref<Record<string, LoadingTypes>>({});
|
||||
|
||||
// Fetch reservations for a specific date range
|
||||
const fetchReservationsForDateRange = async (
|
||||
start: string = today(),
|
||||
end: string = start
|
||||
) => {
|
||||
const startDate = new Date(start + 'T00:00');
|
||||
const endDate = new Date(end + '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, undefined);
|
||||
}
|
||||
};
|
||||
|
||||
// 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)
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
getReservationsByDate,
|
||||
fetchReservationsForDateRange,
|
||||
isReservationOverlapped,
|
||||
isResourceTimeOverlapped,
|
||||
getConflictingReservations,
|
||||
};
|
||||
});
|
||||
65
src/stores/sampledata/boat.ts
Normal file
65
src/stores/sampledata/boat.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
export const getSampleData = () => [
|
||||
{
|
||||
$id: '1',
|
||||
name: 'ProjectX',
|
||||
displayName: 'PX',
|
||||
class: 'J/27',
|
||||
year: 1981,
|
||||
imgSrc: '/tmpimg/j27.png',
|
||||
iconSrc: '/tmpimg/projectx_avatar256.png',
|
||||
bookingAvailable: true,
|
||||
maxPassengers: 8,
|
||||
requiredCerts: [],
|
||||
defects: [
|
||||
{
|
||||
type: 'engine',
|
||||
severity: 'moderate',
|
||||
description: 'Fuel line leaks at engine fitting.',
|
||||
detail: `The gasket in the end of the fuel hose is damaged, and does not properly seal.
|
||||
This will cause fuel to leak, and will allow air into the fuel chamber, causing a lean mixture,
|
||||
and rough engine performance.`,
|
||||
},
|
||||
{
|
||||
type: 'rigging',
|
||||
severity: 'moderate',
|
||||
description: 'Tiller extension is broken.',
|
||||
detail:
|
||||
'The tiller extension swivel is broken, and will not attach to the tiller.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
$id: '2',
|
||||
name: 'Take5',
|
||||
displayName: 'T5',
|
||||
class: 'J/27',
|
||||
year: 1985,
|
||||
imgSrc: '/tmpimg/j27.png',
|
||||
iconsrc: '/tmpimg/take5_avatar32.png',
|
||||
bookingAvailable: true,
|
||||
maxPassengers: 8,
|
||||
requiredCerts: [],
|
||||
},
|
||||
{
|
||||
$id: '3',
|
||||
name: 'WeeBeestie',
|
||||
displayName: 'WB',
|
||||
class: 'Capri 25',
|
||||
year: 1989,
|
||||
imgSrc: '/tmpimg/capri25.png',
|
||||
bookingAvailable: true,
|
||||
maxPassengers: 6,
|
||||
requiredCerts: [],
|
||||
},
|
||||
{
|
||||
$id: '4',
|
||||
name: 'Just My Imagination',
|
||||
displayName: 'JMI',
|
||||
class: 'Sirius 28',
|
||||
year: 1989,
|
||||
imgSrc: '/tmpimg/JMI.jpg',
|
||||
bookingAvailable: true,
|
||||
maxPassengers: 8,
|
||||
requiredCerts: [],
|
||||
},
|
||||
];
|
||||
143
src/stores/sampledata/schedule.ts
Normal file
143
src/stores/sampledata/schedule.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { DateOptions, date } from 'quasar';
|
||||
import { Boat, useBoatStore } from '../boat';
|
||||
import {
|
||||
parseTimestamp,
|
||||
today,
|
||||
Timestamp,
|
||||
addToDate,
|
||||
} from '@quasar/quasar-ui-qcalendar';
|
||||
|
||||
import type {
|
||||
StatusTypes,
|
||||
Reservation,
|
||||
IntervalTemplate,
|
||||
Interval,
|
||||
TimeTuple,
|
||||
} from '../schedule.types';
|
||||
|
||||
export const templateA: IntervalTemplate = {
|
||||
id: '1',
|
||||
name: 'WeekdayBlocks',
|
||||
timeTuples: [
|
||||
['08:00', '12:00'],
|
||||
['12:00', '16:00'],
|
||||
['17:00', '21:00'],
|
||||
],
|
||||
};
|
||||
|
||||
export const templateB: IntervalTemplate = {
|
||||
id: '2',
|
||||
name: 'WeekendBlocks',
|
||||
timeTuples: [
|
||||
['07:00', '10:00'],
|
||||
['10:00', '13:00'],
|
||||
['13:00', '16:00'],
|
||||
['16:00', '19:00'],
|
||||
],
|
||||
};
|
||||
|
||||
export function getSampleIntervals(): Interval[] {
|
||||
// Hard-code 30 days worth of blocks, for now. Make them random templates
|
||||
const boats = useBoatStore().boats;
|
||||
const result: Interval[] = [];
|
||||
const tsToday: Timestamp = parseTimestamp(today()) as Timestamp;
|
||||
|
||||
for (let i = 0; i <= 30; i++) {
|
||||
const template = templateB;
|
||||
result.push(
|
||||
...boats
|
||||
.map((b): Interval[] => {
|
||||
return template.blocks.map((t: TimeTuple): Interval => {
|
||||
return {
|
||||
$id: 'id' + Math.random().toString(32).slice(2),
|
||||
boatId: b.$id,
|
||||
start: addToDate(tsToday, { day: i }).date + ' ' + t[0],
|
||||
end: addToDate(tsToday, { day: i }).date + ' ' + t[1],
|
||||
};
|
||||
});
|
||||
})
|
||||
.flat(2)
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getSampleReservations(): Reservation[] {
|
||||
const sampleData = [
|
||||
{
|
||||
id: '1',
|
||||
user: 'John Smith',
|
||||
start: '7:00',
|
||||
end: '10:00',
|
||||
boat: '66359729003825946ae1',
|
||||
status: 'confirmed',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
user: 'Bob Barker',
|
||||
start: '16:00',
|
||||
end: '19:00',
|
||||
boat: '66359729003825946ae1',
|
||||
status: 'confirmed',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
user: 'Peter Parker',
|
||||
start: '7:00',
|
||||
end: '13:00',
|
||||
boat: '663597030029b71c7a9b',
|
||||
status: 'tentative',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
user: 'Vince McMahon',
|
||||
start: '10:00',
|
||||
end: '13:00',
|
||||
boat: '663597030029b71c7a9b',
|
||||
status: 'pending',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
user: 'Heather Graham',
|
||||
start: '13:00',
|
||||
end: '19:00',
|
||||
boat: '663596b9000235ffea55',
|
||||
status: 'confirmed',
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
user: 'Lawrence Fishburne',
|
||||
start: '13:00',
|
||||
end: '16:00',
|
||||
boat: '663596b9000235ffea55',
|
||||
},
|
||||
];
|
||||
const boatStore = useBoatStore();
|
||||
const now = new Date();
|
||||
const splitTime = (x: string): string[] => {
|
||||
return x.split(':');
|
||||
};
|
||||
const makeOpts = (x: string[]): DateOptions => {
|
||||
return {
|
||||
hour: parseInt(x[0]),
|
||||
minute: parseInt(x[1]),
|
||||
seconds: 0,
|
||||
milliseconds: 0,
|
||||
};
|
||||
};
|
||||
|
||||
return sampleData.map((entry): Reservation => {
|
||||
const boat = <Boat>boatStore.boats.find((b) => b.$id == entry.boat);
|
||||
return {
|
||||
id: entry.id,
|
||||
user: entry.user,
|
||||
start: date
|
||||
.adjustDate(now, makeOpts(splitTime(entry.start)))
|
||||
.toISOString(),
|
||||
end: date.adjustDate(now, makeOpts(splitTime(entry.end))).toISOString(),
|
||||
resource: boat.$id,
|
||||
reservationDate: now,
|
||||
status: entry.status as StatusTypes,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1,140 +1,265 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
import { Boat, useBoatStore } from './boat';
|
||||
import { date } from 'quasar';
|
||||
import { DateOptions } from 'quasar';
|
||||
import { ComputedRef, computed, ref } from 'vue';
|
||||
import { Boat } from './boat';
|
||||
import {
|
||||
Timestamp,
|
||||
parseDate,
|
||||
parsed,
|
||||
compareDate,
|
||||
} from '@quasar/quasar-ui-qcalendar';
|
||||
|
||||
export interface Reservation {
|
||||
id: number;
|
||||
user: string;
|
||||
start: Date;
|
||||
end: Date;
|
||||
resource: Boat;
|
||||
reservationDate: Date;
|
||||
status?: string;
|
||||
import { IntervalTemplate, TimeTuple, Interval } from './schedule.types';
|
||||
import { AppwriteIds, databases } from 'src/boot/appwrite';
|
||||
import { ID, Models } from 'appwrite';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function getSampleData(): Reservation[] {
|
||||
const sampleData = [
|
||||
{
|
||||
id: 1,
|
||||
user: 'John Smith',
|
||||
start: '12:00',
|
||||
end: '14:00',
|
||||
boat: 1,
|
||||
status: 'confirmed',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
user: 'Bob Barker',
|
||||
start: '18:00',
|
||||
end: '20:00',
|
||||
boat: 1,
|
||||
status: 'confirmed',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
user: 'Peter Parker',
|
||||
start: '8:00',
|
||||
end: '10:00',
|
||||
boat: 2,
|
||||
status: 'tentative',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
user: 'Vince McMahon',
|
||||
start: '13:00',
|
||||
end: '17:00',
|
||||
boat: 2,
|
||||
status: 'pending',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
user: 'Heather Graham',
|
||||
start: '06:00',
|
||||
end: '09:00',
|
||||
boat: 3,
|
||||
status: 'confirmed',
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
user: 'Lawrence Fishburne',
|
||||
start: '18:00',
|
||||
end: '20:00',
|
||||
boat: 3,
|
||||
},
|
||||
];
|
||||
const boatStore = useBoatStore();
|
||||
const now = new Date();
|
||||
const splitTime = (x: string): string[] => {
|
||||
return x.split(':');
|
||||
};
|
||||
const makeOpts = (x: string[]): DateOptions => {
|
||||
return { hour: parseInt(x[0]), minute: parseInt(x[1]) };
|
||||
};
|
||||
|
||||
return sampleData.map((entry): Reservation => {
|
||||
const boat = <Boat>boatStore.boats.find((b) => b.id == entry.boat);
|
||||
return {
|
||||
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,
|
||||
};
|
||||
export function timeTuplesOverlapped(tuples: TimeTuple[]): Interval[] {
|
||||
return blocksOverlapped(
|
||||
tuples.map((tuples) => {
|
||||
return {
|
||||
boatId: '',
|
||||
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 blocksOverlapped(blocks: Interval[] | Interval[]): Interval[] {
|
||||
return Array.from(
|
||||
new Set(
|
||||
blocks
|
||||
.sort((a, b) => Date.parse(a.start) - Date.parse(b.start))
|
||||
.reduce((acc: Interval[], block, i, arr) => {
|
||||
if (i > 0 && block.start < arr[i - 1].end)
|
||||
acc.push(arr[i - 1], block);
|
||||
return acc;
|
||||
}, [])
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function copyTimeTuples(tuples: TimeTuple[]): TimeTuple[] {
|
||||
return tuples.map((t) => Object.assign([], t));
|
||||
}
|
||||
export function copyIntervalTemplate(
|
||||
template: IntervalTemplate
|
||||
): IntervalTemplate {
|
||||
return {
|
||||
...template,
|
||||
timeTuples: copyTimeTuples(template.timeTuples || []),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildInterval(
|
||||
resource: Boat,
|
||||
time: TimeTuple,
|
||||
blockDate: string
|
||||
): Interval {
|
||||
/* When the time zone offset is absent, date-only forms are interpreted
|
||||
as a UTC time and date-time forms are interpreted as local time. */
|
||||
const result = {
|
||||
boatId: resource.$id,
|
||||
start: new Date(blockDate + 'T' + time[0]).toISOString(),
|
||||
end: new Date(blockDate + 'T' + time[1]).toISOString(),
|
||||
};
|
||||
return result;
|
||||
}
|
||||
|
||||
export const useScheduleStore = defineStore('schedule', () => {
|
||||
const reservations = ref<Reservation[]>(getSampleData());
|
||||
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)
|
||||
// TODO: Implement functions to dynamically pull this data.
|
||||
const intervals = ref<Interval[]>([]);
|
||||
const intervalTemplates = ref<IntervalTemplate[]>([]);
|
||||
|
||||
const getIntervals = (
|
||||
date: Timestamp,
|
||||
boat: Boat
|
||||
): ComputedRef<Interval[]> => {
|
||||
return computed(() =>
|
||||
intervals.value.filter((block) => {
|
||||
return (
|
||||
compareDate(parseDate(new Date(block.start)) as Timestamp, date) &&
|
||||
block.boatId === boat.$id
|
||||
);
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const getIntervalsForDate = (date: string): Interval[] => {
|
||||
// TODO: This needs to actually make sure we have the dates we need, stay in sync, etc.
|
||||
|
||||
return intervals.value.filter((b) => {
|
||||
return compareDate(
|
||||
parseDate(new Date(b.start)) as Timestamp,
|
||||
parsed(date) as Timestamp
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const isOverlapped = (res: Reservation) => {
|
||||
const lapped = reservations.value.filter(
|
||||
(entry: Reservation) =>
|
||||
entry.id != res.id &&
|
||||
entry.resource == res.resource &&
|
||||
((entry.start <= res.start && entry.end > res.start) ||
|
||||
(entry.end >= res.end && entry.start <= res.end))
|
||||
);
|
||||
return lapped.length > 0;
|
||||
};
|
||||
async function fetchIntervals() {
|
||||
try {
|
||||
const response = await databases.listDocuments(
|
||||
AppwriteIds.databaseId,
|
||||
AppwriteIds.collection.timeBlock
|
||||
);
|
||||
intervals.value = response.documents as Interval[];
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch timeblocks', error);
|
||||
}
|
||||
}
|
||||
|
||||
const getNewId = () => {
|
||||
// Trivial placeholder
|
||||
return Math.max(...reservations.value.map((item) => item.id)) + 1;
|
||||
};
|
||||
async function fetchIntervalTemplates() {
|
||||
try {
|
||||
const response = await databases.listDocuments(
|
||||
AppwriteIds.databaseId,
|
||||
AppwriteIds.collection.timeBlockTemplate
|
||||
);
|
||||
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 getConflicts = (timeblock: Interval, boat: Boat) => {
|
||||
// const start = date.buildDate({
|
||||
// hour: timeblock.start.hour,
|
||||
// minute: timeblock.start.minute,
|
||||
// second: 0,
|
||||
// millisecond: 0,
|
||||
// });
|
||||
// const end = date.buildDate({
|
||||
// hour: timeblock.end.hour,
|
||||
// minute: timeblock.end.minute,
|
||||
// second: 0,
|
||||
// millisecond: 0,
|
||||
// });
|
||||
// return scheduleStore.getConflictingReservations(boat, start, end);
|
||||
// };
|
||||
|
||||
const addOrCreateReservation = (reservation: Reservation) => {
|
||||
const index = reservations.value.findIndex(
|
||||
(res) => res.id == reservation.id
|
||||
);
|
||||
index != -1
|
||||
? (reservations.value[index] = reservation)
|
||||
: reservations.value.push(reservation);
|
||||
const createInterval = async (interval: Interval) => {
|
||||
try {
|
||||
const response = await databases.createDocument(
|
||||
AppwriteIds.databaseId,
|
||||
AppwriteIds.collection.timeBlock,
|
||||
ID.unique(),
|
||||
interval
|
||||
);
|
||||
intervals.value.push(response as Interval);
|
||||
} catch (e) {
|
||||
console.error('Error creating Interval: ' + e);
|
||||
}
|
||||
};
|
||||
const updateInterval = async (interval: Interval) => {
|
||||
try {
|
||||
if (interval.$id) {
|
||||
const response = await databases.updateDocument(
|
||||
AppwriteIds.databaseId,
|
||||
AppwriteIds.collection.timeBlock,
|
||||
interval.$id,
|
||||
{ ...interval, $id: undefined }
|
||||
);
|
||||
intervals.value.push(response as Interval);
|
||||
console.log(`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.timeBlock,
|
||||
id
|
||||
);
|
||||
intervals.value = intervals.value.filter((block) => block.$id !== id);
|
||||
} catch (e) {
|
||||
console.error('Error deleting Interval: ' + e);
|
||||
}
|
||||
};
|
||||
const createIntervalTemplate = async (template: IntervalTemplate) => {
|
||||
try {
|
||||
const response = await databases.createDocument(
|
||||
AppwriteIds.databaseId,
|
||||
AppwriteIds.collection.timeBlockTemplate,
|
||||
ID.unique(),
|
||||
{ name: template.name, timeTuple: template.timeTuples.flat(2) }
|
||||
);
|
||||
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.timeBlockTemplate,
|
||||
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.timeBlockTemplate,
|
||||
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 {
|
||||
reservations,
|
||||
getBoatReservations,
|
||||
getNewId,
|
||||
addOrCreateReservation,
|
||||
isOverlapped,
|
||||
timeblocks: intervals,
|
||||
timeblockTemplates: intervalTemplates,
|
||||
getIntervalsForDate,
|
||||
getIntervals,
|
||||
fetchIntervals,
|
||||
fetchIntervalTemplates,
|
||||
createInterval,
|
||||
updateInterval,
|
||||
deleteInterval,
|
||||
createIntervalTemplate,
|
||||
deleteIntervalTemplate,
|
||||
updateIntervalTemplate,
|
||||
};
|
||||
});
|
||||
|
||||
29
src/stores/schedule.types.ts
Normal file
29
src/stores/schedule.types.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Models } from 'appwrite';
|
||||
|
||||
export type StatusTypes = 'tentative' | 'confirmed' | 'pending' | undefined;
|
||||
export type Reservation = Partial<Models.Document> & {
|
||||
user: string;
|
||||
start: string;
|
||||
end: string;
|
||||
resource: string; // Boat ID
|
||||
status?: StatusTypes;
|
||||
};
|
||||
|
||||
// 24 hrs in advance only 2 weekday, and 1 weekend slot
|
||||
// Within 24 hrs, any available slot
|
||||
/* TODO: Figure out how best to separate out where qcalendar bits should be.
|
||||
e.g.: Should there be any qcalendar stuff in this store? Or should we have just JS Date
|
||||
objects in here? */
|
||||
|
||||
export type TimeTuple = [start: string, end: string];
|
||||
export type Interval = Partial<Models.Document> & {
|
||||
boatId: string;
|
||||
start: string;
|
||||
end: string;
|
||||
selected?: false;
|
||||
};
|
||||
|
||||
export type IntervalTemplate = Partial<Models.Document> & {
|
||||
name: string;
|
||||
timeTuples: TimeTuple[];
|
||||
};
|
||||
157
src/stores/task.ts
Normal file
157
src/stores/task.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { AppwriteIds, databases, ID } from 'src/boot/appwrite';
|
||||
import type { Models } from 'appwrite';
|
||||
|
||||
export const TASKSTATUS = ['ready', 'complete', 'waiting', 'archived'];
|
||||
|
||||
export interface Task extends Partial<Models.Document> {
|
||||
title: string;
|
||||
description: string;
|
||||
/* Array of Appwrite Document IDs */
|
||||
required_skills: string[];
|
||||
/* Array of Appwrite Document IDs */
|
||||
tags: string[];
|
||||
due_date: string;
|
||||
duration: number;
|
||||
/* Array of Appwrite Document IDs */
|
||||
volunteers: string[];
|
||||
volunteers_required: number;
|
||||
status: string;
|
||||
/* Array of Appwrite Document IDs */
|
||||
depends_on: string[];
|
||||
/* Appwrite ID of a Boat resource */
|
||||
boat?: string[];
|
||||
}
|
||||
|
||||
export interface TaskTag extends Models.Document {
|
||||
name: string;
|
||||
description: string;
|
||||
colour: string;
|
||||
}
|
||||
export interface SkillTag extends Models.Document {
|
||||
name: string;
|
||||
description: string;
|
||||
tagColour: string;
|
||||
}
|
||||
|
||||
export const useTaskStore = defineStore('tasks', {
|
||||
state: () => ({
|
||||
tasks: [] as Task[],
|
||||
taskTags: [] as TaskTag[],
|
||||
skillTags: [] as SkillTag[],
|
||||
}),
|
||||
|
||||
actions: {
|
||||
async fetchTasks() {
|
||||
try {
|
||||
await this.fetchTaskTags();
|
||||
await this.fetchSkillTags();
|
||||
const response = await databases.listDocuments(
|
||||
AppwriteIds.databaseId,
|
||||
AppwriteIds.collection.task
|
||||
);
|
||||
this.tasks = response.documents as Task[];
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch tasks', error);
|
||||
}
|
||||
},
|
||||
async fetchTaskTags() {
|
||||
// This is fine for a small number of tags, but more than a few hundred tags, we'd need to optimize
|
||||
try {
|
||||
const response = await databases.listDocuments(
|
||||
AppwriteIds.databaseId,
|
||||
AppwriteIds.collection.taskTags
|
||||
);
|
||||
this.taskTags = response.documents as TaskTag[];
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch task tags', error);
|
||||
}
|
||||
},
|
||||
async fetchSkillTags() {
|
||||
// This is fine for a small number of tags, but more than a few hundred tags, we'd need to optimize
|
||||
try {
|
||||
const response = await databases.listDocuments(
|
||||
AppwriteIds.databaseId,
|
||||
AppwriteIds.collection.skillTags
|
||||
);
|
||||
this.skillTags = response.documents as SkillTag[];
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch skill tags', error);
|
||||
}
|
||||
},
|
||||
async deleteTask(task: Task | string) {
|
||||
const docId = typeof task === 'string' ? task : task.$id;
|
||||
if (docId === undefined) {
|
||||
console.error('No document ID provided to deleteTask!');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await databases.deleteDocument(
|
||||
AppwriteIds.databaseId,
|
||||
AppwriteIds.collection.task,
|
||||
docId
|
||||
);
|
||||
this.tasks = this.tasks.filter((task) => docId !== task.$id);
|
||||
} catch (error) {
|
||||
// Need some better error handling, here.
|
||||
console.error('Failed to delete task:', error);
|
||||
}
|
||||
},
|
||||
async addTask(task: Task) {
|
||||
const newTask = <Models.Document>{ ...task };
|
||||
try {
|
||||
const response = await databases.createDocument(
|
||||
AppwriteIds.databaseId,
|
||||
AppwriteIds.collection.task,
|
||||
ID.unique(),
|
||||
newTask
|
||||
);
|
||||
this.tasks.push(response as Task);
|
||||
} catch (error) {
|
||||
console.error('Failed to add task:', error);
|
||||
}
|
||||
},
|
||||
async updateTask(task: Task) {
|
||||
const newTask = <Partial<Models.Document>>{
|
||||
...task,
|
||||
id: undefined,
|
||||
$databaseId: undefined,
|
||||
$collectionId: undefined,
|
||||
};
|
||||
if (!task.$id) {
|
||||
console.error('No Task ID!');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await databases.updateDocument(
|
||||
AppwriteIds.databaseId,
|
||||
AppwriteIds.collection.task,
|
||||
task.$id,
|
||||
newTask
|
||||
);
|
||||
this.tasks.push(response as Task);
|
||||
} catch (error) {
|
||||
console.error('Failed to update task:', error);
|
||||
}
|
||||
},
|
||||
// TODO: Enhance this store to include offline caching, and subscription notification when items change on the server.
|
||||
},
|
||||
// Add more actions as needed (e.g., updateTask, deleteTask)
|
||||
getters: {
|
||||
getTaskById: (state) => (id: string) => {
|
||||
return state.tasks.find((task) => task.$id === id) || null;
|
||||
},
|
||||
getTaskTagById: (state) => (id: string) => {
|
||||
return state.taskTags.find((tag) => tag.$id === id) || null;
|
||||
},
|
||||
getSkillById: (state) => (id: string) => {
|
||||
return state.skillTags.find((tag) => tag.$id === id) || null;
|
||||
},
|
||||
filterTasksByTitle: (state) => (searchQuery: string) => {
|
||||
const result = state.tasks.filter((task) =>
|
||||
task.title.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
return result;
|
||||
},
|
||||
},
|
||||
});
|
||||
7
src/utils/misc.ts
Normal file
7
src/utils/misc.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
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;
|
||||
}
|
||||
@@ -3,4 +3,4 @@
|
||||
"compilerOptions": {
|
||||
"baseUrl": "."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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