91 Commits

Author SHA1 Message Date
3a67f2fbb1 Rename TimeBlock to Interva. More Interval functionality.
Some checks failed
Build BAB Application Deployment Artifact / build (push) Failing after 1m40s
2024-05-10 09:50:04 -04:00
77619b0741 Edits to usability 2024-05-09 12:57:21 -04:00
ea785887a1 Sorted out reactivity with storeToRefs
Some checks failed
Build BAB Application Deployment Artifact / build (push) Failing after 2m1s
2024-05-08 23:43:18 -04:00
b860e1d977 Add some checks 2024-05-08 17:23:23 -04:00
274d0193f7 Some timeblock stuff working 2024-05-08 13:32:10 -04:00
033993b1b8 Upgrade Quasar 2024-05-06 19:22:28 -04:00
2872fb867e Started work on Schedule Management 2024-05-06 17:22:11 -04:00
8e73650462 Clean up all kinds of typescript issues 2024-05-05 15:58:58 -04:00
634cff507c Converted some schedule to use backend 2024-05-04 23:17:05 -04:00
fa4d83e42d Cleanup linting messages. Also, break some things 2024-05-04 12:08:16 -04:00
c92f737612 New image. Update some vars
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m4s
2024-05-02 20:09:41 -04:00
5792e80112 Filtering booked blocks
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 1m53s
2024-05-01 11:02:33 -04:00
db0755a368 Cleanup warnings 2024-05-01 09:56:08 -04:00
2b61d57a8a Stub out passenger info
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m2s
2024-04-30 23:08:17 -04:00
29f9aeaba4 Minor cosmetic cleanup
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m9s
2024-04-30 17:11:11 -04:00
28600578f1 Fix update of timblock 2024-04-30 17:04:55 -04:00
b66afb5692 Change colour of date header to white 2024-04-30 16:04:30 -04:00
2f68877ce6 Updates to booking
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 1m58s
2024-04-30 13:56:42 -04:00
0de9991a49 Fix generated data 2024-04-29 21:46:08 -04:00
4faff7cc8c Selection working
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m0s
2024-04-29 21:21:57 -04:00
c297f1f287 More work on timeblocks
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m0s
2024-04-29 21:14:02 -04:00
43e68c8ae7 Refactor Schedules 2024-04-29 12:54:54 -04:00
e1a784ef45 Add swipe
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m5s
2024-04-29 12:22:57 -04:00
d9cfa4ab56 Convert type to interface
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m1s
2024-04-29 08:37:15 -04:00
cb2131ae7e Fix Build issues
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 1m59s
2024-04-28 20:36:48 -04:00
de04b53914 Time Select
Some checks failed
Build BAB Application Deployment Artifact / build (push) Failing after 38s
2024-04-28 19:07:00 -04:00
1a18881980 Update QCalendar 2024-04-13 21:01:42 -04:00
84867875c5 Updates to booking
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m30s
2024-04-13 12:11:14 -04:00
ea0bc82c49 Task list improvements
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 1m47s
2024-04-08 18:39:00 -04:00
15ef8435f6 Basic filtering and buttong 2024-04-08 13:37:45 -04:00
4c2cae7149 Rudimentary searching 2024-04-08 13:03:33 -04:00
ffaf31bbeb Add searching 2024-04-08 11:28:45 -04:00
6ab1aa26b1 Updates to Tasks 2024-04-08 10:18:55 -04:00
5d9dbb0653 Densify table
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m9s
2024-04-07 10:43:38 -04:00
299ede4aa9 Task List Enhancements 2024-04-06 15:02:48 -04:00
b91ba39d06 Basic Task deletion 2024-04-05 22:01:51 -04:00
8464701082 Added task functionality 2024-04-05 20:50:56 -04:00
b3ce8e59cb Change the colour to Red.
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m6s
2024-04-04 16:17:56 -04:00
55071318ca Test Yellow
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m13s
2024-04-04 11:19:10 -04:00
b66b63101f Break out component for refactoring
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 1m53s
2024-04-04 10:04:36 -04:00
9db1b4d97c Change to green
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m13s
2024-04-03 13:49:04 -04:00
71a8c2e8d2 Fix typo in Route
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m12s
2024-04-03 13:39:30 -04:00
88738715b6 Change build
Some checks failed
Build BAB Application Deployment Artifact / build (push) Has been cancelled
2024-04-03 13:37:37 -04:00
53c650d4b0 Remove Broken component 2024-04-03 13:36:50 -04:00
deb6a0b8ed Basic New Task
Some checks failed
Build BAB Application Deployment Artifact / build (push) Failing after 2m7s
2024-04-03 13:28:35 -04:00
923d09d713 A number of task improvements. Not optimal tag selection
Some checks failed
Build BAB Application Deployment Artifact / build (push) Failing after 2m20s
2024-03-31 14:43:45 -04:00
d752898865 Basic Task Display 2024-03-30 11:45:59 -04:00
435438aaa8 More task changes
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m3s
2024-03-18 10:51:33 -04:00
084aadccef Update tasklist
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m4s
2024-03-12 23:37:25 -04:00
468569fa27 Add some subtasks
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m11s
2024-03-12 23:04:47 -04:00
0986d04ea6 Attempt to add basic tasklist
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m8s
2024-03-12 22:44:24 -04:00
6ff1a69e2b Update dependencies 2024-03-10 17:22:04 -04:00
052cae2c2e Update project
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m27s
2024-03-10 12:54:49 -04:00
29170f9e13 Add personas to docs 2024-03-10 11:45:00 -04:00
25ed6df62a Update quasar
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m11s
2024-03-07 21:49:38 -05:00
2f86700fb7 Change Number
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m33s
2024-02-13 14:56:41 -05:00
e7a79736b7 Change icons to blue
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 3m3s
2024-01-16 14:19:38 -05:00
2d585d499e Final build working
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m50s
2024-01-01 18:56:12 -05:00
284d5ffcb4 Move env file creation to the right place
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 3m2s
2024-01-01 18:48:58 -05:00
27a476ae00 Test
Some checks failed
Build BAB Application Deployment Artifact / build (push) Has been cancelled
2024-01-01 18:47:11 -05:00
ee7f79550c Fix name of .env file
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 3m1s
2024-01-01 18:42:57 -05:00
2ef801905b Update envfile
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 3m3s
2024-01-01 18:30:54 -05:00
752421c9fc Add the env file, so app builds with correct API info
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 3m3s
2024-01-01 17:39:11 -05:00
ce169f6a61 reorder install
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m56s
2024-01-01 13:42:03 -05:00
622b9fc82d Install dependencies with yarn
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m58s
2024-01-01 13:19:10 -05:00
275f23c421 Remove npm package-lock.json
Some checks failed
Build BAB Application Deployment Artifact / build (push) Failing after 59s
2024-01-01 13:12:48 -05:00
88ed4caf5b Update all yarn packages
Some checks failed
Build BAB Application Deployment Artifact / build (push) Failing after 2m2s
2024-01-01 13:10:30 -05:00
346e395e15 Build tar, as all the dates are messed up with zip
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m23s
2023-12-31 15:32:09 -05:00
f30848803b Update Boat selection component
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m33s
2023-12-31 15:04:53 -05:00
96dab93483 Fix URL Path
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m29s
2023-12-29 23:58:04 -05:00
a6abee1ddf Enable verbosity for debugging
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m29s
2023-12-29 12:45:19 -05:00
b20f2bffd6 Remove Secret
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m29s
2023-12-29 12:33:36 -05:00
f6689cbc5c Change url from secret to var
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m37s
2023-12-29 12:19:39 -05:00
8383605115 Disable SSL verification
Some checks failed
Build BAB Application Deployment Artifact / build (push) Failing after 2m31s
2023-12-29 12:14:20 -05:00
f69614d5c7 Fix URL again
Some checks failed
Build BAB Application Deployment Artifact / build (push) Failing after 2m40s
2023-12-29 12:06:24 -05:00
f7902011cc Fix action url
Some checks failed
Build BAB Application Deployment Artifact / build (push) Failing after 5s
2023-12-29 12:04:21 -05:00
e86876ba69 Update Actions
Some checks failed
Build BAB Application Deployment Artifact / build (push) Failing after 5s
2023-12-29 11:29:31 -05:00
cd6f2e3ba2 Updates to selection component
Some checks failed
Build BAB Application Deployment Artifact / build (push) Failing after 2m27s
2023-12-29 09:22:28 -05:00
66e2169f45 Adapting to time blocks for bookings
Some checks failed
Build BAB Application Deployment Artifact / build (push) Failing after 2m11s
2023-12-23 11:39:54 -05:00
489cc2646b Try v4 of the upload-artifact action
Some checks failed
Build BAB Application Deployment Artifact / build (push) Failing after 2m15s
2023-12-20 14:23:42 -05:00
295f1f7449 Don't bother tar/gz, as it's adding an extra, unnecessary step
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m20s
2023-12-20 13:59:50 -05:00
33a1bc24f6 Test run number addition to build
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m21s
2023-12-20 12:27:27 -05:00
d18780bb21 Begin implementation of timeblocks. Update workflow to build on devel branch
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m21s
2023-12-20 10:48:51 -05:00
ef569ac3b1 Merge minor edits from development.
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m30s
2023-12-18 20:50:51 -05:00
9390b7035c Change interval to 1h. Create StatusTypes 2023-12-18 20:44:01 -05:00
ac1730401a Add a shortened displayName for boats for a better mobile experience 2023-12-18 20:23:17 -05:00
bc41b1a7a1 Add bordered logo
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 3m42s
2023-12-18 18:10:05 -05:00
ea566d4a42 Add docs folder and design of users
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 2m31s
2023-12-18 14:26:03 -05:00
573e327a0f Move hard-coded API settings to .env file 2023-12-03 08:51:01 -05:00
831e81e892 Update package.json 2023-12-03 08:19:57 -05:00
39a6ab5fcc New workflow steps to use version
All checks were successful
Build BAB Application Deployment Artifact / build (push) Successful in 3m9s
2023-12-02 23:19:02 -05:00
49 changed files with 4599 additions and 1809 deletions

View File

@@ -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,35 @@ 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: 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
View File

@@ -0,0 +1,4 @@
{
"projectId": "65ede55a213134f2b688",
"projectName": ""
}

3
backup/.env Normal file
View 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
View File

@@ -0,0 +1,4 @@
{
"projectId": "65ede55a213134f2b688",
"projectName": ""
}

View File

@@ -0,0 +1,8 @@
# Personas
- BAB Member
- Certified Skipper
- Program Administrator
- Boatswain
- Volunteer
- Instructor

View 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

View File

@@ -13,35 +13,39 @@
"build": "quasar build"
},
"dependencies": {
"@quasar/extras": "^1.16.4",
"appwrite": "^13.0.0",
"@quasar/extras": "^1.16.11",
"appwrite": "^14.0.1",
"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",
"@quasar/quasar-app-extension-qcalendar": "^4.0.0-beta.16",
"@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

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

View File

@@ -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,15 @@ 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/, ''),
},
},
// For reverse-proxying via haproxy
// hmr: {
// clientPort: 443,
@@ -93,7 +113,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

View File

@@ -2,10 +2,20 @@
<router-view />
</template>
<script lang="ts">
import { defineComponent } from 'vue';
<script setup lang="ts">
import { defineComponent, onMounted } from 'vue';
import { useScheduleStore } from './stores/schedule';
import { useBoatStore } from './stores/boat';
import { useAuthStore } from './stores/auth';
export default defineComponent({
defineComponent({
name: 'OYS Borrow-a-Boat',
});
onMounted(async () => {
await useAuthStore().init();
await useScheduleStore().fetchIntervalTemplates();
await useScheduleStore().fetchIntervals();
await useBoatStore().fetchBoats();
});
</script>

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

View File

@@ -13,11 +13,23 @@ 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: {
task: '65ee1cd5b550023fae4f',
taskTags: '65ee21d72d5c8007c34c',
skillTags: '66072582a74d94a4bd01',
boat: '66341910003e287cd71c',
timeBlock: '66361869002883fb4c4b',
timeBlockTemplate: '66361f480007fdd639af',
},
};
const account = new Account(client);
const databases = new Databases(client);
@@ -86,4 +98,4 @@ function login(email: string, password: string) {
});
});
}
export { client, account, databases, ID, appDatabaseId, login, logout };
export { client, account, databases, ID, AppwriteIds, login, logout };

View File

@@ -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>

View File

@@ -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,7 +101,6 @@
><template v-slot:append><q-icon name="timelapse" /></template></q-select
></q-card-section>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import {
@@ -114,15 +116,26 @@ import { Boat, useBoatStore } from 'src/stores/boat';
import { useScheduleStore } from 'src/stores/schedule';
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'],
@@ -159,8 +172,8 @@ function monthFormatter() {
function getEvents(scope: ResourceIntervalScope) {
const resourceEvents = scheduleStore.getBoatReservations(
scope.resource.id,
date.extractDate(selectedDate.value, 'YYYY-MM-DD')
parseDate(date.extractDate(selectedDate.value, 'YYYY-MM-DD')) as Timestamp,
scope.resource.$id
);
return resourceEvents.map((event) => {
@@ -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

View File

@@ -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>

View File

@@ -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">

View File

@@ -0,0 +1,174 @@
<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) => {
console.log(template);
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') {
console.log(template.$id);
scheduleStore.updateIntervalTemplate(template, template.$id);
} else {
scheduleStore.createIntervalTemplate(template);
emit('saved');
}
}
};
</script>

View 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')">
&lt; Prev
</q-btn>
<q-btn no-caps class="button" style="margin: 2px" @click="$emit('next')">
Next &gt;
</q-btn>
</div>
</div>
</template>
<script setup lang="ts">
defineEmits(['today', 'prev', 'next']);
</script>

View File

@@ -0,0 +1,259 @@
<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="r in boats[scope.columnIndex].reservations"
:key="r.id"
>
<div
class="reservation"
:style="
reservationStyles(
r,
scope.timeStartPos,
scope.timeDurationHeight
)
"
>
{{ r.user }}
</div>
</div> -->
</template>
</QCalendarDay>
</div>
</div>
</template>
<script setup lang="ts">
import {
QCalendarDay,
Timestamp,
diffTimestamp,
today,
parseTimestamp,
parseDate,
addToDate,
} from '@quasar/quasar-ui-qcalendar';
import CalendarHeaderComponent from './CalendarHeaderComponent.vue';
import { ref, computed } from 'vue';
import { useBoatStore } from 'src/stores/boat';
import { useScheduleStore } from 'src/stores/schedule';
import { Interval } from 'src/stores/schedule.types';
import { storeToRefs } from 'pinia';
const scheduleStore = useScheduleStore();
const { boats } = storeToRefs(useBoatStore());
const selectedBlock = defineModel<Interval | null>();
const selectedDate = ref(today());
const calendar = ref<QCalendarDay | null>(null);
function handleSwipe({ ...event }) {
event.direction === 'right' ? calendar.value?.prev() : calendar.value?.next();
}
// function reservationStyles(
// reservation: Reservation,
// timeStartPos: (t: string) => string,
// timeDurationHeight: (d: number) => string
// ) {
// return genericBlockStyle(
// parseDate(reservation.start) as Timestamp,
// parseDate(reservation.end) as Timestamp,
// timeStartPos,
// timeDurationHeight
// );
// }
function blockStyles(
block: Interval,
timeStartPos: (t: string) => string,
timeDurationHeight: (d: number) => string
) {
return genericBlockStyle(
parseDate(new Date(block.start)) as Timestamp,
parseDate(new Date(block.end)) as Timestamp,
timeStartPos,
timeDurationHeight
);
}
function getBoatDisplayName(scope: DayBodyScope) {
return boats && boats.value[scope.columnIndex]
? boats.value[scope.columnIndex].displayName
: '';
}
function genericBlockStyle(
start: Timestamp,
end: Timestamp,
timeStartPos: (t: string) => string,
timeDurationHeight: (d: number) => string
) {
const s = {
top: '',
height: '',
opacity: '',
};
if (timeStartPos && timeDurationHeight) {
s.top = timeStartPos(start.time) + 'px';
s.height =
parseInt(
timeDurationHeight(diffTimestamp(start, end, false) / 1000 / 60)
) -
1 +
'px';
}
// if (selectedBlock.value?.id === block.id) {
// s.opacity = '1.0';
// }
return s;
}
interface DayBodyScope {
columnIndex: number;
timeDurationHeight: string;
timeStartPos: (time: string, clamp: boolean) => string;
timestamp: Timestamp;
}
function selectBlock(event: MouseEvent, scope: DayBodyScope, block: Interval) {
// TODO: Disable blocks before today with updateDisabled and/or comparison
selectedBlock.value === block
? (selectedBlock.value = null)
: (selectedBlock.value = block);
}
interface BoatBlocks {
[key: string]: Interval[];
}
const boatBlocks = computed((): BoatBlocks => {
return scheduleStore
.getIntervalsForDate(selectedDate.value)
.reduce((result, tb) => {
if (!result[tb.boatId]) result[tb.boatId] = [];
result[tb.boatId].push(tb);
return result;
}, <BoatBlocks>{});
});
function getBoatBlocks(scope: DayBodyScope): Interval[] {
return boats.value[scope.columnIndex]
? boatBlocks.value[boats.value[scope.columnIndex].$id]
: [];
}
// function changeEvent({ start }: { start: string }) {
// const newBlocks = scheduleStore.getIntervalsForDate(start);
// const reservations = scheduleStore.getBoatReservations(
// parsed(start) as Timestamp
// );
// boats.value.map((boat) => {
// boat.reservations = reservations.filter(
// (reservation) => reservation.resource === boat
// );
// boat.blocks = newBlocks.filter(
// (block) =>
// block.boatId === boat.$id &&
// boat.reservations?.filter(
// (r: Reservation) =>
// r.start <
// date.addToDate(makeDateTime(parsed(block.end) as Timestamp), {
// hours: 4,
// }) &&
// r.end >
// date.addToDate(makeDateTime(parsed(block.start) as Timestamp), {
// hours: 4,
// })
// ).length == 0
// );
// });
// setTimeout(() => calendar.value?.scrollToTime('09:00'), 100); // Should figure out why we need this setTimeout...
// }
const disabledBefore = computed(() => {
const todayTs = parseTimestamp(today()) as Timestamp;
return addToDate(todayTs, { day: -1 }).date;
});
</script>
<style lang="sass">
.boat-schedule-table-component
display: flex
max-height: 60vh
max-width: 98vw
.reservation
display: flex
position: absolute
justify-content: center
align-items: center
text-align: center
width: 100%
opacity: 1
margin: 0px
text-overflow: ellipsis
font-size: 0.8em
cursor: pointer
background: $accent
color: white
border: 1px solid black
.timeblock
display: flex
position: absolute
justify-content: center
text-align: center
align-items: center
width: 100%
opacity: 0.5
margin: 0px
text-overflow: ellipsis
font-size: 0.8em
cursor: pointer
background: $primary
color: white
border: 1px solid black
.selected
opacity: 1 !important
.q-calendar-day__interval--text
font-size: 0.8em
.q-calendar-day__day.q-current-day
padding: 1px
</style>

View 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>

View 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>

View File

@@ -0,0 +1,269 @@
<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() {
//console.log(modifiedTask);
try {
if (modifiedTask.$id) {
await taskStore.updateTask(modifiedTask);
console.log('Updated Task: ' + modifiedTask.$id);
} else {
await taskStore.addTask(modifiedTask);
console.log('Created Task');
}
router.go(-1);
} catch (error) {
console.error('Failed to create new Task: ', error);
}
}
</script>

View 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>

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -0,0 +1,8 @@
<template>
<q-page padding>
<!-- content -->
</q-page>
</template>
<script setup lang="ts">
</script>

View File

@@ -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,53 @@
</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 { useScheduleStore } from 'src/stores/schedule';
import { Interval } from 'src/stores/schedule.types';
import BoatScheduleTableComponent from 'src/components/scheduling/boat/BoatScheduleTableComponent.vue';
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({
const timeblock = ref<Interval>();
const bookingForm = ref<BookingForm>({
bookingId: scheduleStore.getNewId(),
name: auth.currentUser?.name,
boat: <Boat | undefined>undefined,
startDate: date.formatDate(new Date(), dateFormat),
endDate: computed(() =>
date.formatDate(
date.addToDate(bookingForm.startDate, {
hours: bookingForm.duration,
}),
dateFormat
)
),
duration: 1,
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);
console.log(tb_new);
});
// //TODO: Turn this into a validator.
// scheduleStore.isReservationOverlapped(newRes)
// ? Dialog.create({ message: 'This booking overlaps another!' })
// : scheduleStore.addOrCreateReservation(newRes);
const onReset = () => {
// TODO
};
@@ -126,38 +152,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>

View File

@@ -1,12 +1,128 @@
<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 + ' - ' + 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 { Reservation } from 'src/stores/schedule.types';
import { ref } from 'vue';
const scheduleStore = useScheduleStore();
scheduleStore.loadSampleData();
import { TimestampOrNull, parseDate, today } from '@quasar/quasar-ui-qcalendar';
import { QCalendarDay } from '@quasar/quasar-ui-qcalendar';
import { date } from 'quasar';
import { Timestamp } from '@quasar/quasar-ui-qcalendar';
const selectedDate = ref(today());
// 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(parseDate(event.start)) + 'px';
s.height =
timeDurationHeight(date.getDateDiff(event.end, event.start, 'minutes')) +
'px';
}
return s;
}
function reservationEvents(timestamp: Timestamp) {
return scheduleStore.getBoatReservations(timestamp);
}
function onMoved(data: Event) {
console.log('onMoved', data);
}
function onChange(data: Event) {
console.log('onChange', data);
}
function onClickDate(data: Event) {
console.log('onClickDate', data);
}
function onClickTime(data: Event) {
console.log('onClickTime', data);
}
function onClickInterval(data: Event) {
console.log('onClickInterval', data);
}
function onClickHeadDay(data: Event) {
console.log('onClickHeadDay', data);
}
</script>
<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>

View File

@@ -0,0 +1,297 @@
<template>
<div class="fit row wrap justify-start items-start content-start">
<div class="q-pa-md">
<div class="scheduler" style="max-width: 1200px">
<NavigationBar @next="onNext" @today="onToday" @prev="onPrev" />
<q-calendar-scheduler
ref="calendar"
v-model="selectedDate"
v-model:model-resources="boats"
resource-key="$id"
resource-label="name"
view="week"
:weekdays="[1, 2, 3, 4, 5, 6, 0]"
animated
bordered
:drag-enter-func="onDragEnter"
:drag-over-func="onDragOver"
:drag-leave-func="onDragLeave"
:drop-func="onDrop"
:day-min-height="50"
:cell-width="150"
:day-height="0"
>
<template #day="{ scope }">
<div
v-if="getIntervals(scope.timestamp, scope.resource)"
style="
display: flex;
flex-wrap: wrap;
justify-content: space-evenly;
align-items: center;
font-size: 12px;
"
>
<template
v-for="block in getIntervals(
scope.timestamp,
scope.resource
).sort((a, b) => Date.parse(a.start) - Date.parse(b.start))"
:key="block.id"
>
<q-chip class="cursor-pointer">
{{ date.formatDate(block.start, 'HH:mm') }} -
{{ date.formatDate(block.end, 'HH:mm') }}
<q-popup-edit
:model-value="block"
v-slot="scope"
buttons
@save="updateInterval(block)"
>
<q-input
:model-value="date.formatDate(scope.value.start, 'HH:mm')"
dense
autofocus
type="time"
label="start"
@keyup.enter="scope.set"
@update:model-value="
(t) =>
(block.start = buildISODate(
date.formatDate(scope.value.start, 'YYYY-MM-DD'),t as string
))
"
/>
<!-- TODO: Clean this up -->
<q-input
:model-value="date.formatDate(scope.value.end, 'HH:mm')"
dense
type="time"
label="end"
@keyup.enter="scope.set"
@update:model-value="
(t) =>
(block.end = buildISODate(
date.formatDate(scope.value.end, 'YYYY-MM-DD'),t as string
))
"
/>
</q-popup-edit> </q-chip
><q-btn
size="xs"
icon="delete"
round
@click="deleteBlock(block)"
/>
</template>
</div>
</template>
</q-calendar-scheduler>
</div>
</div>
<div class="q-pa-md" style="width: 400">
<q-list padding bordered class="rounded-borders">
<q-item>
<q-item-section>
<q-item-label overline>Availability Templates</q-item-label>
<q-item-label caption
>Drag and drop a template to a boat / date to create booking
availability</q-item-label
>
</q-item-section>
</q-item>
<q-card-actions align="right">
<q-btn label="Add Template" color="primary" @click="createTemplate" />
</q-card-actions>
<q-item v-if="newTemplate.$id === 'unsaved'"
><IntervalTemplateComponent
:model-value="newTemplate"
:edit="true"
@cancel="resetNewTemplate"
@saved="resetNewTemplate"
/></q-item>
<q-separator spaced />
<IntervalTemplateComponent
v-for="template in timeblockTemplates"
:key="template.$id"
:model-value="template"
/>
</q-list>
</div>
</div>
<q-dialog v-model="alert">
<q-card>
<q-card-section>
<div class="text-h6">Warning!</div>
</q-card-section>
<q-card-section class="q-pt-none">
This will overwrite existing blocks!
{{ overlapped }}
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="OK" color="primary" v-close-popup />
</q-card-actions> </q-card
></q-dialog>
</template>
<script setup lang="ts">
import {
QCalendarScheduler,
Timestamp,
today,
} from '@quasar/quasar-ui-qcalendar';
import { Boat, useBoatStore } from 'src/stores/boat';
import {
blocksOverlapped,
buildInterval,
useScheduleStore,
buildISODate,
} 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 { getIntervals, fetchIntervals, updateInterval, fetchIntervalTemplates } =
useScheduleStore();
const { boats } = storeToRefs(useBoatStore());
const { timeblockTemplates } = storeToRefs(useScheduleStore());
const calendar = ref();
const overlapped = ref();
const blankTemplate: IntervalTemplate = {
$id: '',
name: 'NewTemplate',
timeTuples: [['09:00', '12:00']],
};
const newTemplate = ref<IntervalTemplate>({ ...blankTemplate });
const alert = ref(false);
/* TODOS:
* Need more validation:
- Interval start < end
- Intervals don't overlap
* Need to handle case of overnight blocks.
*/
onMounted(async () => {
await fetchBoats();
await fetchIntervals();
await fetchIntervalTemplates();
});
function resetNewTemplate() {
newTemplate.value = { ...blankTemplate };
}
function createTemplate() {
newTemplate.value.$id = 'unsaved';
}
function createIntervals(boat: Boat, templateId: string, date: string) {
timeBlocksFromTemplate(boat, templateId, date)?.map((block) =>
useScheduleStore().createInterval(block)
);
}
function timeBlocksFromTemplate(
boat: Boat,
templateId: string,
date: string
): Interval[] {
const timeBlock = timeblockTemplates.value.find((t) => t.$id === templateId);
return (
timeBlock?.timeTuples.map((tb: TimeTuple) =>
buildInterval(boat, tb, date)
) || []
);
}
function deleteBlock(block: Interval) {
if (block.$id) {
useScheduleStore().deleteInterval(block.$id);
}
return false;
}
function onDragEnter(e: DragEvent, type: string) {
if (type === 'day' || type === 'head-day') {
e.preventDefault();
if (e.target instanceof HTMLDivElement)
e.target.classList.add('bg-secondary');
}
}
function onDragOver(e: DragEvent, type: string) {
if (type === 'day' || type === 'head-day') {
e.preventDefault();
return true;
}
}
function onDragLeave(e: DragEvent, type: string) {
if (type === 'day' || type === 'head-day') {
e.preventDefault();
if (e.target instanceof HTMLDivElement)
e.target.classList.remove('bg-secondary');
return false;
}
}
function onDrop(
//TODO: Move all overlap checking to the store. This is too messy right now.
e: DragEvent,
type: string,
scope: { resource: Boat; timestamp: Timestamp }
) {
if ((type === 'day' || type === 'head-day') && e.dataTransfer) {
const templateId = e.dataTransfer.getData('ID');
const date = scope.timestamp.date;
if (type === 'head-day') {
overlapped.value = boats.value.map((boat) =>
blocksOverlapped(
getIntervals(scope.timestamp, boat).concat(
timeBlocksFromTemplate(boat, templateId, date)
)
)
);
console.log(overlapped);
if (overlapped.value.length === 0) {
boats.value.map((b) => createIntervals(b, templateId, date));
} else {
alert.value = true;
}
} else {
overlapped.value = blocksOverlapped(
getIntervals(scope.timestamp, scope.resource).concat(
timeBlocksFromTemplate(scope.resource, templateId, date)
)
);
if (overlapped.value.length === 0) {
createIntervals(scope.resource, templateId, date);
} else {
alert.value = true;
}
}
}
if (e.target instanceof HTMLDivElement)
e.target.classList.remove('bg-secondary');
return false;
}
function onToday() {
calendar.value.moveToToday();
}
function onPrev() {
calendar.value.prev();
}
function onNext() {
calendar.value.next();
}
</script>

View File

@@ -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>

View 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>

View 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>

View File

@@ -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 {

View File

@@ -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,

View File

@@ -1,11 +1,10 @@
import { defineStore } from 'pinia';
import { ID, account } from 'boot/appwrite';
import type { Models } from 'appwrite';
import { 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);
async function init() {
try {
@@ -13,7 +12,6 @@ export const useAuthStore = defineStore('auth', () => {
} catch {
currentUser.value = null;
}
ready.value = true;
}
async function register(email: string, password: string) {
@@ -21,12 +19,12 @@ 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'
);
@@ -37,5 +35,5 @@ export const useAuthStore = defineStore('auth', () => {
return account.deleteSession('current').then((currentUser.value = null));
}
return { currentUser, register, login, googleLogin, logout, init, ready };
return { currentUser, register, login, googleLogin, logout, init };
});

View File

@@ -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,20 @@ 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: {},
actions: {
// update () {
// }
},
return { boats, fetchBoats };
});

View 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: [],
},
];

View File

@@ -0,0 +1,141 @@
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: '1',
status: 'confirmed',
},
{
id: '2',
user: 'Bob Barker',
start: '16:00',
end: '19:00',
boat: '1',
status: 'confirmed',
},
{
id: '3',
user: 'Peter Parker',
start: '7:00',
end: '13:00',
boat: '4',
status: 'tentative',
},
{
id: '4',
user: 'Vince McMahon',
start: '10:00',
end: '13:00',
boat: '2',
status: 'pending',
},
{
id: '5',
user: 'Heather Graham',
start: '13:00',
end: '19:00',
boat: '4',
status: 'confirmed',
},
{
id: '6',
user: 'Lawrence Fishburne',
start: '13:00',
end: '16:00',
boat: '3',
},
];
const boatStore = useBoatStore();
const now = new Date();
const splitTime = (x: string): string[] => {
return x.split(':');
};
const makeOpts = (x: string[]): DateOptions => {
return {
hour: parseInt(x[0]),
minute: parseInt(x[1]),
seconds: 0,
milliseconds: 0,
};
};
return sampleData.map((entry): Reservation => {
const boat = <Boat>boatStore.boats.find((b) => b.$id == entry.boat);
return {
id: entry.id,
user: entry.user,
start: date.adjustDate(now, makeOpts(splitTime(entry.start))),
end: date.adjustDate(now, makeOpts(splitTime(entry.end))),
resource: boat,
reservationDate: now,
status: entry.status as StatusTypes,
};
});
}

View File

@@ -1,124 +1,206 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { Boat, useBoatStore } from './boat';
import { date } from 'quasar';
import { DateOptions } from 'quasar';
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 {
Reservation,
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 buildISODate(date: string, time: string | null): string {
return new Date(date + 'T' + time || '00:00').toISOString();
}
export function buildInterval(
resource: Boat,
time: TimeTuple,
blockDate: string
): Interval {
/* When the time zone offset is absent, date-only forms are interpreted
as a UTC time and date-time forms are interpreted as local time. */
const result = {
boatId: resource.$id,
start: buildISODate(blockDate, time[0]),
end: buildISODate(blockDate, time[1]),
};
return result;
}
export const useScheduleStore = defineStore('schedule', () => {
const reservations = ref<Reservation[]>(getSampleData());
const getBoatReservations = (
boat: number | string,
curDate: Date
): Reservation[] => {
return reservations.value.filter((x) => {
// TODO: Implement functions to dynamically pull this data.
const reservations = ref<Reservation[]>([]);
const timeblocks = ref<Interval[]>([]);
const timeblockTemplates = ref<IntervalTemplate[]>([]);
const getIntervals = (date: Timestamp, boat: Boat): Interval[] => {
return timeblocks.value.filter((block) => {
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)
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 timeblocks.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;
const getBoatReservations = (
searchDate: Timestamp,
boat?: string
): Reservation[] => {
return reservations.value.filter((x) => {
return (
((parseDate(x.start)?.date == searchDate.date ||
parseDate(x.end)?.date == searchDate.date) && // Part of reservation falls on day
x.resource != undefined && // A boat is defined
!boat) ||
x.resource.$id == boat // A specific boat has been passed, and matches
);
});
};
const getNewId = () => {
async function fetchIntervals() {
try {
const response = await databases.listDocuments(
AppwriteIds.databaseId,
AppwriteIds.collection.timeBlock
);
timeblocks.value = response.documents as Interval[];
} catch (error) {
console.error('Failed to fetch timeblocks', error);
}
}
async function fetchIntervalTemplates() {
try {
const response = await databases.listDocuments(
AppwriteIds.databaseId,
AppwriteIds.collection.timeBlockTemplate
);
timeblockTemplates.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 getConflictingReservations = (
resource: Boat,
start: Date,
end: Date
): Reservation[] => {
const overlapped = reservations.value.filter(
(entry: Reservation) =>
entry.resource.$id == resource.$id &&
entry.start < end &&
entry.end > start
);
return overlapped;
};
const isResourceTimeOverlapped = (
resource: Boat,
start: Date,
end: Date
): boolean => {
return getConflictingReservations(resource, start, end).length > 0;
};
const isReservationOverlapped = (res: Reservation): boolean => {
return isResourceTimeOverlapped(res.resource, res.start, res.end);
};
const getNewId = (): string => {
return [...Array(20)]
.map(() => Math.floor(Math.random() * 16).toString(16))
.join('');
// Trivial placeholder
return Math.max(...reservations.value.map((item) => item.id)) + 1;
//return Math.max(...reservations.value.map((item) => item.id)) + 1;
};
const addOrCreateReservation = (reservation: Reservation) => {
@@ -130,11 +212,122 @@ export const useScheduleStore = defineStore('schedule', () => {
: reservations.value.push(reservation);
};
const createInterval = async (interval: Interval) => {
try {
const response = await databases.createDocument(
AppwriteIds.databaseId,
AppwriteIds.collection.timeBlock,
ID.unique(),
interval
);
timeblocks.value.push(response as Interval);
} catch (e) {
console.error('Error creating Interval: ' + e);
}
};
const updateInterval = async (interval: Interval) => {
try {
if (interval.$id) {
const response = await databases.updateDocument(
AppwriteIds.databaseId,
AppwriteIds.collection.timeBlock,
interval.$id,
{ ...interval, $id: undefined }
);
timeblocks.value.push(response as Interval);
console.log(interval, response);
} else {
console.error('Update interval called without an ID');
}
} catch (e) {
console.error('Error updating Interval: ' + e);
}
};
const deleteInterval = async (id: string) => {
try {
await databases.deleteDocument(
AppwriteIds.databaseId,
AppwriteIds.collection.timeBlock,
id
);
timeblocks.value = timeblocks.value.filter((block) => block.$id !== id);
} catch (e) {
console.error('Error deleting Interval: ' + e);
}
};
const createIntervalTemplate = async (template: IntervalTemplate) => {
try {
const response = await databases.createDocument(
AppwriteIds.databaseId,
AppwriteIds.collection.timeBlockTemplate,
ID.unique(),
{ name: template.name, timeTuple: template.timeTuples.flat(2) }
);
timeblockTemplates.value.push(response as IntervalTemplate);
} catch (e) {
console.error('Error updating IntervalTemplate: ' + e);
}
};
const deleteIntervalTemplate = async (id: string) => {
try {
await databases.deleteDocument(
AppwriteIds.databaseId,
AppwriteIds.collection.timeBlockTemplate,
id
);
timeblockTemplates.value = timeblockTemplates.value.filter(
(template) => template.$id !== id
);
} catch (e) {
console.error('Error deleting IntervalTemplate: ' + e);
}
};
const updateIntervalTemplate = async (
template: IntervalTemplate,
id: string
) => {
try {
const response = await databases.updateDocument(
AppwriteIds.databaseId,
AppwriteIds.collection.timeBlockTemplate,
id,
{
name: template.name,
timeTuple: template.timeTuples.flat(2),
}
);
timeblockTemplates.value = timeblockTemplates.value.map((b) =>
b.$id !== id
? b
: ({
...response,
timeTuples: arrayToTimeTuples(response.timeTuple),
} as IntervalTemplate)
);
} catch (e) {
console.error('Error updating IntervalTemplate: ' + e);
}
};
return {
reservations,
timeblocks,
timeblockTemplates,
getBoatReservations,
getConflictingReservations,
getIntervalsForDate,
getIntervals,
fetchIntervals,
fetchIntervalTemplates,
getNewId,
addOrCreateReservation,
isOverlapped,
createInterval,
updateInterval,
deleteInterval,
createIntervalTemplate,
deleteIntervalTemplate,
updateIntervalTemplate,
isReservationOverlapped,
isResourceTimeOverlapped,
};
});

View File

@@ -0,0 +1,31 @@
import { Models } from 'appwrite';
import type { Boat } from './boat';
export type StatusTypes = 'tentative' | 'confirmed' | 'pending' | undefined;
export interface Reservation {
id: string;
user: string;
start: Date;
end: Date;
resource: Boat;
reservationDate: Date;
status?: StatusTypes;
}
// 24 hrs in advance only 2 weekday, and 1 weekend slot
// Within 24 hrs, any available slot
/* TODO: Figure out how best to separate out where qcalendar bits should be.
e.g.: Should there be any qcalendar stuff in this store? Or should we have just JS Date
objects in here? */
export type TimeTuple = [start: string, end: string];
export type Interval = Partial<Models.Document> & {
boatId: string;
start: string;
end: string;
selected?: false;
};
export type IntervalTemplate = Partial<Models.Document> & {
name: string;
timeTuples: TimeTuple[];
};

158
src/stores/task.ts Normal file
View File

@@ -0,0 +1,158 @@
import { defineStore } from 'pinia';
import { AppwriteIds, databases, ID } from 'src/boot/appwrite';
import type { Models } from 'appwrite';
export const TASKSTATUS = ['ready', 'complete', 'waiting', 'archived'];
export interface Task extends Partial<Models.Document> {
title: string;
description: string;
/* Array of Appwrite Document IDs */
required_skills: string[];
/* Array of Appwrite Document IDs */
tags: string[];
due_date: string;
duration: number;
/* Array of Appwrite Document IDs */
volunteers: string[];
volunteers_required: number;
status: string;
/* Array of Appwrite Document IDs */
depends_on: string[];
/* Appwrite ID of a Boat resource */
boat?: string[];
}
export interface TaskTag extends Models.Document {
name: string;
description: string;
colour: string;
}
export interface SkillTag extends Models.Document {
name: string;
description: string;
tagColour: string;
}
export const useTaskStore = defineStore('tasks', {
state: () => ({
tasks: [] as Task[],
taskTags: [] as TaskTag[],
skillTags: [] as SkillTag[],
}),
actions: {
async fetchTasks() {
try {
await this.fetchTaskTags();
await this.fetchSkillTags();
const response = await databases.listDocuments(
AppwriteIds.databaseId,
AppwriteIds.collection.task
);
this.tasks = response.documents as Task[];
} catch (error) {
console.error('Failed to fetch tasks', error);
}
},
async fetchTaskTags() {
// This is fine for a small number of tags, but more than a few hundred tags, we'd need to optimize
try {
const response = await databases.listDocuments(
AppwriteIds.databaseId,
AppwriteIds.collection.taskTags
);
this.taskTags = response.documents as TaskTag[];
} catch (error) {
console.error('Failed to fetch task tags', error);
}
},
async fetchSkillTags() {
// This is fine for a small number of tags, but more than a few hundred tags, we'd need to optimize
try {
const response = await databases.listDocuments(
AppwriteIds.databaseId,
AppwriteIds.collection.skillTags
);
this.skillTags = response.documents as SkillTag[];
} catch (error) {
console.error('Failed to fetch skill tags', error);
}
},
async deleteTask(task: Task | string) {
const docId = typeof task === 'string' ? task : task.$id;
if (docId === undefined) {
console.error('No document ID provided to deleteTask!');
return;
}
try {
await databases.deleteDocument(
AppwriteIds.databaseId,
AppwriteIds.collection.task,
docId
);
this.tasks = this.tasks.filter((task) => docId !== task.$id);
} catch (error) {
// Need some better error handling, here.
console.error('Failed to delete task:', error);
}
},
async addTask(task: Task) {
const newTask = <Models.Document>{ ...task };
try {
const response = await databases.createDocument(
AppwriteIds.databaseId,
AppwriteIds.collection.task,
ID.unique(),
newTask
);
this.tasks.push(response as Task);
} catch (error) {
console.error('Failed to add task:', error);
}
},
async updateTask(task: Task) {
const newTask = <Partial<Models.Document>>{
...task,
id: undefined,
$databaseId: undefined,
$collectionId: undefined,
};
if (!task.$id) {
console.error('No Task ID!');
return;
}
try {
const response = await databases.updateDocument(
AppwriteIds.databaseId,
AppwriteIds.collection.task,
task.$id,
newTask
);
this.tasks.push(response as Task);
} catch (error) {
console.error('Failed to update task:', error);
}
},
// TODO: Enhance this store to include offline caching, and subscription notification when items change on the server.
},
// Add more actions as needed (e.g., updateTask, deleteTask)
getters: {
getTaskById: (state) => (id: string) => {
return state.tasks.find((task) => task.$id === id) || null;
},
getTaskTagById: (state) => (id: string) => {
return state.taskTags.find((tag) => tag.$id === id) || null;
},
getSkillById: (state) => (id: string) => {
return state.skillTags.find((tag) => tag.$id === id) || null;
},
filterTasksByTitle: (state) => (searchQuery: string) => {
const result = state.tasks.filter((task) =>
task.title.toLowerCase().includes(searchQuery.toLowerCase())
);
console.log(result);
return result;
},
},
});

View File

@@ -3,4 +3,4 @@
"compilerOptions": {
"baseUrl": "."
}
}
}

6
tsconfig.vue-tsc.json Normal file
View File

@@ -0,0 +1,6 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"skipLibCheck": true
}
}

0
v1 Normal file
View File

2993
yarn.lock

File diff suppressed because it is too large Load Diff