Compare commits
91 Commits
v0.0.2
...
3a67f2fbb1
| Author | SHA1 | Date | |
|---|---|---|---|
|
3a67f2fbb1
|
|||
|
77619b0741
|
|||
|
ea785887a1
|
|||
|
b860e1d977
|
|||
|
274d0193f7
|
|||
|
033993b1b8
|
|||
|
2872fb867e
|
|||
|
8e73650462
|
|||
|
634cff507c
|
|||
|
fa4d83e42d
|
|||
|
c92f737612
|
|||
|
5792e80112
|
|||
|
db0755a368
|
|||
|
2b61d57a8a
|
|||
|
29f9aeaba4
|
|||
|
28600578f1
|
|||
|
b66afb5692
|
|||
|
2f68877ce6
|
|||
|
0de9991a49
|
|||
|
4faff7cc8c
|
|||
|
c297f1f287
|
|||
|
43e68c8ae7
|
|||
|
e1a784ef45
|
|||
|
d9cfa4ab56
|
|||
|
cb2131ae7e
|
|||
|
de04b53914
|
|||
|
1a18881980
|
|||
|
84867875c5
|
|||
|
ea0bc82c49
|
|||
|
15ef8435f6
|
|||
|
4c2cae7149
|
|||
|
ffaf31bbeb
|
|||
|
6ab1aa26b1
|
|||
|
5d9dbb0653
|
|||
|
299ede4aa9
|
|||
|
b91ba39d06
|
|||
|
8464701082
|
|||
|
b3ce8e59cb
|
|||
|
55071318ca
|
|||
|
b66b63101f
|
|||
|
9db1b4d97c
|
|||
|
71a8c2e8d2
|
|||
|
88738715b6
|
|||
|
53c650d4b0
|
|||
|
deb6a0b8ed
|
|||
|
923d09d713
|
|||
|
d752898865
|
|||
|
435438aaa8
|
|||
|
084aadccef
|
|||
|
468569fa27
|
|||
|
0986d04ea6
|
|||
|
6ff1a69e2b
|
|||
|
052cae2c2e
|
|||
|
29170f9e13
|
|||
|
25ed6df62a
|
|||
|
2f86700fb7
|
|||
|
e7a79736b7
|
|||
|
2d585d499e
|
|||
|
284d5ffcb4
|
|||
|
27a476ae00
|
|||
|
ee7f79550c
|
|||
|
2ef801905b
|
|||
|
752421c9fc
|
|||
|
ce169f6a61
|
|||
|
622b9fc82d
|
|||
|
275f23c421
|
|||
|
88ed4caf5b
|
|||
|
346e395e15
|
|||
|
f30848803b
|
|||
|
96dab93483
|
|||
|
a6abee1ddf
|
|||
|
b20f2bffd6
|
|||
|
f6689cbc5c
|
|||
|
8383605115
|
|||
|
f69614d5c7
|
|||
|
f7902011cc
|
|||
|
e86876ba69
|
|||
|
cd6f2e3ba2
|
|||
|
66e2169f45
|
|||
|
489cc2646b
|
|||
|
295f1f7449
|
|||
|
33a1bc24f6
|
|||
|
d18780bb21
|
|||
|
ef569ac3b1
|
|||
|
9390b7035c
|
|||
|
ac1730401a
|
|||
|
bc41b1a7a1
|
|||
|
ea566d4a42
|
|||
|
573e327a0f
|
|||
|
831e81e892
|
|||
|
39a6ab5fcc
|
@@ -1,5 +1,5 @@
|
||||
name: Build BAB Application Deployment Artifact
|
||||
run-name: ${{ gitea.actor }} is building an artifact 🚀
|
||||
run-name: ${{ gitea.actor }} is building a BAB App artifact 🚀
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
@@ -15,16 +15,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
4
appwrite.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"projectId": "65ede55a213134f2b688",
|
||||
"projectName": ""
|
||||
}
|
||||
3
backup/.env
Normal file
3
backup/.env
Normal file
@@ -0,0 +1,3 @@
|
||||
APPWRITE_ENDPOINT=https://apidev.bab.toal.ca/v1
|
||||
APPWRITE_PROJECT_ID=65ede55a213134f2b688
|
||||
APPWRITE_API_KEY=71f7f899ca605b39a3f24a80a23b34f580fd7e735316152bc0d5ed042bd452e7116c4d0a7f3c77d343690d6cce229020c76de1733c754a402f15bbe9b2cab5a6cd7b3a7c1c0d66cede4f6aee99cdfac14898b7a2006a5eaae24529bbcb19b4c2f6563adff5688dda9c15357c9e98b449e50b6794dfb8cc6ab61e9f073b08a11e
|
||||
4
backup/appwrite.json
Normal file
4
backup/appwrite.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"projectId": "65ede55a213134f2b688",
|
||||
"projectName": ""
|
||||
}
|
||||
8
docs/planning/personas.md
Normal file
8
docs/planning/personas.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# Personas
|
||||
|
||||
- BAB Member
|
||||
- Certified Skipper
|
||||
- Program Administrator
|
||||
- Boatswain
|
||||
- Volunteer
|
||||
- Instructor
|
||||
40
docs/users_roles_permissions.md
Normal file
40
docs/users_roles_permissions.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# Users, Roles and Permissions
|
||||
|
||||
This is the design document for https://gitea.toal.ca/oys/bab-app/issues/11
|
||||
|
||||
## Backend Concepts
|
||||
|
||||
Utilizing the AppWrite backend provides us with some basic concepts we can use:
|
||||
|
||||
### Users, Groups, and Labels
|
||||
|
||||
#### Teams
|
||||
|
||||
Teams are AppWrite groups of users. Teams can be assigned roles, which can be assigned permissions. Teams "contain" users. A team has more permissions to manage it's members than labels, which are assigned / removed, rather than 'invited / left'.
|
||||
|
||||
#### Labels
|
||||
|
||||
Labels are AppWrite tags for users. Users have Labels as attributes. Like teams, labels can be used for Role / Permission mapping.
|
||||
|
||||
### Permissions
|
||||
|
||||
https://appwrite.io/docs/advanced/platform/permissions
|
||||
|
||||
Permissions are fine-grained access control for users and objects. They follow standard "CRUD" patterns.
|
||||
|
||||
## BAB Concepts
|
||||
|
||||
For teams, there will, to start, be the following:
|
||||
|
||||
- `staff` : Individuals with authority / responsibilities
|
||||
- `maintenance` : Staff responsible for maintenance (eg: Boatswain)
|
||||
- `admin`: Administrators of the program / application
|
||||
- `school` : Members of the Sailing School (Instructors & Students)
|
||||
- `student` role : A student in the school
|
||||
- `instructor` role: An instructor in the school
|
||||
- `bab` : Members of the BAB program
|
||||
- `skipper` role: A member who has passed skipper certification
|
||||
|
||||
The following are the initial labels:
|
||||
|
||||
- TBD
|
||||
24
package.json
24
package.json
@@ -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
BIN
public/tmpimg/JMI.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 130 KiB |
@@ -48,11 +48,11 @@ module.exports = configure(function (/* ctx */) {
|
||||
|
||||
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#build
|
||||
build: {
|
||||
env: require('dotenv').config({ path: '.env.local' }).parsed,
|
||||
target: {
|
||||
browser: ['es2019', 'edge88', 'firefox78', 'chrome87', 'safari13.1'],
|
||||
node: 'node16',
|
||||
},
|
||||
|
||||
vueRouterMode: 'hash', // available values: 'hash', 'history'
|
||||
// vueRouterBase,
|
||||
// vueDevtools,
|
||||
@@ -72,9 +72,20 @@ module.exports = configure(function (/* ctx */) {
|
||||
// extendViteConf (viteConf) {},
|
||||
// viteVuePluginOptions: {},
|
||||
|
||||
// vitePlugins: [
|
||||
// [ 'package-name', { ..options.. } ]
|
||||
// ]
|
||||
vitePlugins: [
|
||||
[
|
||||
'vite-plugin-checker',
|
||||
{
|
||||
vueTsc: {
|
||||
tsconfigPath: 'tsconfig.vue-tsc.json',
|
||||
},
|
||||
eslint: {
|
||||
lintCommand: 'eslint "./**/*.{js,ts,mjs,cjs,vue}"',
|
||||
},
|
||||
},
|
||||
{ server: false },
|
||||
],
|
||||
],
|
||||
},
|
||||
|
||||
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#devServer
|
||||
@@ -83,6 +94,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
|
||||
|
||||
16
src/App.vue
16
src/App.vue
@@ -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>
|
||||
|
||||
BIN
src/assets/OYS-Burgee_square.png
Normal file
BIN
src/assets/OYS-Burgee_square.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.9 KiB |
BIN
src/assets/oysqn_logo_only_bordered.png
Normal file
BIN
src/assets/oysqn_logo_only_bordered.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 80 KiB |
@@ -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 };
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<p>{{ title }}</p>
|
||||
<ul>
|
||||
<li v-for="todo in todos" :key="todo.id" @click="increment">
|
||||
{{ todo.id }} - {{ todo.content }}
|
||||
</li>
|
||||
</ul>
|
||||
<p>Count: {{ todoCount }} / {{ meta.totalCount }}</p>
|
||||
<p>Active: {{ active ? 'yes' : 'no' }}</p>
|
||||
<p>Clicks on todos: {{ clickCount }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {
|
||||
defineComponent,
|
||||
PropType,
|
||||
computed,
|
||||
ref,
|
||||
toRef,
|
||||
Ref,
|
||||
} from 'vue';
|
||||
import { Todo, Meta } from './models';
|
||||
|
||||
function useClickCount() {
|
||||
const clickCount = ref(0);
|
||||
function increment() {
|
||||
clickCount.value += 1
|
||||
return clickCount.value;
|
||||
}
|
||||
|
||||
return { clickCount, increment };
|
||||
}
|
||||
|
||||
function useDisplayTodo(todos: Ref<Todo[]>) {
|
||||
const todoCount = computed(() => todos.value.length);
|
||||
return { todoCount };
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ExampleComponent',
|
||||
props: {
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
todos: {
|
||||
type: Array as PropType<Todo[]>,
|
||||
default: () => []
|
||||
},
|
||||
meta: {
|
||||
type: Object as PropType<Meta>,
|
||||
required: true
|
||||
},
|
||||
active: {
|
||||
type: Boolean
|
||||
}
|
||||
},
|
||||
setup (props) {
|
||||
return { ...useClickCount(), ...useDisplayTodo(toRef(props, 'todos')) };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -1,3 +1,4 @@
|
||||
<!-- This has been abandoned for now. Going to block-based booking. Will probably need the schedule viewer functionality at some point in the future, though -->
|
||||
<template>
|
||||
<q-card-section>
|
||||
<div class="text-caption text-justify">
|
||||
@@ -55,11 +56,13 @@
|
||||
v-model="selectedDate"
|
||||
:model-resources="boatStore.boats"
|
||||
resource-key="id"
|
||||
resource-label="name"
|
||||
:interval-start="12"
|
||||
:interval-count="36"
|
||||
:interval-minutes="30"
|
||||
resource-label="displayName"
|
||||
resource-width="32"
|
||||
:interval-start="6"
|
||||
:interval-count="18"
|
||||
:interval-minutes="60"
|
||||
cell-width="48"
|
||||
style="--calendar-resources-width: 48px"
|
||||
resource-min-height="40"
|
||||
animated
|
||||
bordered
|
||||
@@ -79,8 +82,8 @@
|
||||
</template>
|
||||
|
||||
<template #resource-label="{ scope: { resource } }">
|
||||
<div class="col-12">
|
||||
{{ resource.name }}
|
||||
<div class="col-12 .col-md-auto">
|
||||
{{ resource.displayName }}
|
||||
<q-icon v-if="resource.defects" name="warning" color="warning" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -98,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
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<q-item-section avatar>
|
||||
<q-img v-if="boat?.iconsrc" :src="boat?.iconsrc" />
|
||||
<q-img v-if="boat?.iconSrc" :src="boat?.iconSrc" />
|
||||
<q-icon v-else name="sailing" />
|
||||
</q-item-section>
|
||||
</template>
|
||||
|
||||
@@ -1,23 +1,26 @@
|
||||
<template>
|
||||
<q-card v-for="boat in boats" :key="boat.id" flat class="mobile-card">
|
||||
<q-card-section>
|
||||
<q-img :src="boat.imgsrc" :fit="'scale-down'">
|
||||
<div class="row absolute-top">
|
||||
<div class="col text-h5 text-left">{{ boat.name }}</div>
|
||||
<div class="col text-right">{{ boat.class }}</div>
|
||||
</div>
|
||||
</q-img>
|
||||
</q-card-section>
|
||||
<div v-if="boats">
|
||||
<q-card v-for="boat in boats" :key="boat.id" flat class="mobile-card">
|
||||
<q-card-section>
|
||||
<q-img :src="boat.imgSrc" :fit="'scale-down'">
|
||||
<div class="row absolute-top">
|
||||
<div class="col text-h6 text-left">{{ boat.name }}</div>
|
||||
<div class="col text-right">{{ boat.class }}</div>
|
||||
</div>
|
||||
</q-img>
|
||||
</q-card-section>
|
||||
|
||||
<q-separator />
|
||||
<q-separator />
|
||||
|
||||
<q-card-actions align="evenly">
|
||||
<q-btn flat>Info</q-btn>
|
||||
<q-btn flat>Book</q-btn>
|
||||
<q-btn flat>Check-Out</q-btn>
|
||||
<q-btn flat>Check-In</q-btn>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
<q-card-actions align="evenly">
|
||||
<q-btn flat>Info</q-btn>
|
||||
<q-btn flat>Book</q-btn>
|
||||
<q-btn flat>Check-Out</q-btn>
|
||||
<q-btn flat>Check-In</q-btn>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</div>
|
||||
<div v-else><q-card>Sorry, no boats to show you!</q-card></div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
0
src/components/scheduling/BoatSelection.vue
Normal file
0
src/components/scheduling/BoatSelection.vue
Normal file
174
src/components/scheduling/IntervalTemplateComponent.vue
Normal file
174
src/components/scheduling/IntervalTemplateComponent.vue
Normal 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>
|
||||
19
src/components/scheduling/NavigationBar.vue
Normal file
19
src/components/scheduling/NavigationBar.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<div class="row justify-center">
|
||||
<div class="q-pa-md q-gutter-sm row">
|
||||
<q-btn no-caps class="button" style="margin: 2px" @click="$emit('today')">
|
||||
Today
|
||||
</q-btn>
|
||||
<q-btn no-caps class="button" style="margin: 2px" @click="$emit('prev')">
|
||||
< Prev
|
||||
</q-btn>
|
||||
<q-btn no-caps class="button" style="margin: 2px" @click="$emit('next')">
|
||||
Next >
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineEmits(['today', 'prev', 'next']);
|
||||
</script>
|
||||
259
src/components/scheduling/boat/BoatScheduleTableComponent.vue
Normal file
259
src/components/scheduling/boat/BoatScheduleTableComponent.vue
Normal 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>
|
||||
259
src/components/scheduling/boat/CalendarHeaderComponent.vue
Normal file
259
src/components/scheduling/boat/CalendarHeaderComponent.vue
Normal file
@@ -0,0 +1,259 @@
|
||||
<template>
|
||||
<div class="title-bar" style="display: flex">
|
||||
<button
|
||||
tabindex="0"
|
||||
class="date-button direction-button direction-button__left"
|
||||
@click="onPrev"
|
||||
>
|
||||
<span class="q-calendar__focus-helper" tabindex="-1" />
|
||||
</button>
|
||||
<div class="dates-holder">
|
||||
<div :key="parsedStart?.date" class="internal-dates-holder">
|
||||
<div v-for="day in days" :key="day.date" :style="dayStyle">
|
||||
<button
|
||||
tabindex="0"
|
||||
style="width: 100%"
|
||||
:class="dayClass(day)"
|
||||
@click="selectedDate = day.date"
|
||||
>
|
||||
<span class="q-calendar__focus-helper" tabindex="-1" />
|
||||
<div style="width: 100%; font-size: 0.9em">
|
||||
{{ monthFormatter(day, true) }}
|
||||
</div>
|
||||
<div style="width: 100%; font-size: 1.2em; font-weight: 700">
|
||||
{{ dayFormatter(day, false) }}
|
||||
</div>
|
||||
<div style="width: 100%; font-size: 1em">
|
||||
{{ weekdayFormatter(day, true) }}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
tabindex="0"
|
||||
class="date-button direction-button direction-button__right"
|
||||
@click="onNext"
|
||||
>
|
||||
<span class="q-calendar__focus-helper" tabindex="-1" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Timestamp,
|
||||
addToDate,
|
||||
createDayList,
|
||||
createNativeLocaleFormatter,
|
||||
getEndOfWeek,
|
||||
getStartOfWeek,
|
||||
getWeekdaySkips,
|
||||
parseTimestamp,
|
||||
today,
|
||||
} from '@quasar/quasar-ui-qcalendar';
|
||||
|
||||
import { ref, reactive, computed } from 'vue';
|
||||
|
||||
const selectedDate = defineModel<string>();
|
||||
|
||||
const weekdays = reactive([1, 2, 3, 4, 5, 6, 0]),
|
||||
locale = ref('en-CA'),
|
||||
monthFormatter = monthFormatterFunc(),
|
||||
dayFormatter = dayFormatterFunc(),
|
||||
weekdayFormatter = weekdayFormatterFunc();
|
||||
|
||||
const weekdaySkips = computed(() => {
|
||||
return getWeekdaySkips(weekdays);
|
||||
});
|
||||
|
||||
const parsedStart = computed(() =>
|
||||
getStartOfWeek(
|
||||
parseTimestamp(selectedDate.value || today()) as Timestamp,
|
||||
weekdays,
|
||||
today2.value as Timestamp
|
||||
)
|
||||
);
|
||||
|
||||
const parsedEnd = computed(() =>
|
||||
getEndOfWeek(
|
||||
parseTimestamp(selectedDate.value || today()) as Timestamp,
|
||||
weekdays,
|
||||
today2.value as Timestamp
|
||||
)
|
||||
);
|
||||
|
||||
const today2 = computed(() => {
|
||||
return parseTimestamp(today());
|
||||
});
|
||||
|
||||
const days = computed(() => {
|
||||
if (parsedStart.value && parsedEnd.value) {
|
||||
return createDayList(
|
||||
parsedStart.value,
|
||||
parsedEnd.value,
|
||||
today2.value as Timestamp,
|
||||
weekdaySkips.value
|
||||
);
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const dayStyle = computed(() => {
|
||||
const width = 100 / weekdays.length + '%';
|
||||
return {
|
||||
width,
|
||||
};
|
||||
});
|
||||
|
||||
function onPrev() {
|
||||
const ts = addToDate(parsedStart.value, { day: -7 });
|
||||
selectedDate.value = ts.date;
|
||||
}
|
||||
|
||||
function onNext() {
|
||||
const ts = addToDate(parsedStart.value, { day: 7 });
|
||||
selectedDate.value = ts.date;
|
||||
}
|
||||
|
||||
function dayClass(day: Timestamp) {
|
||||
return {
|
||||
'date-button': true,
|
||||
'selected-date-button': selectedDate.value === day.date,
|
||||
};
|
||||
}
|
||||
|
||||
function monthFormatterFunc() {
|
||||
const longOptions: Intl.DateTimeFormatOptions = {
|
||||
timeZone: 'UTC',
|
||||
month: 'long',
|
||||
};
|
||||
const shortOptions: Intl.DateTimeFormatOptions = {
|
||||
timeZone: 'UTC',
|
||||
month: 'short',
|
||||
};
|
||||
|
||||
return createNativeLocaleFormatter(locale.value, (_tms, short) =>
|
||||
short ? shortOptions : longOptions
|
||||
);
|
||||
}
|
||||
|
||||
function weekdayFormatterFunc() {
|
||||
const longOptions: Intl.DateTimeFormatOptions = {
|
||||
timeZone: 'UTC',
|
||||
weekday: 'long',
|
||||
};
|
||||
const shortOptions: Intl.DateTimeFormatOptions = {
|
||||
timeZone: 'UTC',
|
||||
weekday: 'short',
|
||||
};
|
||||
return createNativeLocaleFormatter(locale.value, (_tms, short) =>
|
||||
short ? shortOptions : longOptions
|
||||
);
|
||||
}
|
||||
|
||||
function dayFormatterFunc() {
|
||||
const longOptions: Intl.DateTimeFormatOptions = {
|
||||
timeZone: 'UTC',
|
||||
day: '2-digit',
|
||||
};
|
||||
const shortOptions: Intl.DateTimeFormatOptions = {
|
||||
timeZone: 'UTC',
|
||||
day: 'numeric',
|
||||
};
|
||||
|
||||
return createNativeLocaleFormatter(locale.value, (_tms, short) =>
|
||||
short ? shortOptions : longOptions
|
||||
);
|
||||
}
|
||||
</script>
|
||||
<style lang="sass">
|
||||
.title-bar
|
||||
position: relative
|
||||
width: 100%
|
||||
height: 70px
|
||||
background: white
|
||||
display: flex
|
||||
flex-direction: row
|
||||
flex: 1 0 100%
|
||||
justify-content: space-between
|
||||
align-items: center
|
||||
overflow: hidden
|
||||
border-radius: 3px
|
||||
user-select: none
|
||||
margin: 2px 0px 2px
|
||||
|
||||
.dates-holder
|
||||
position: relative
|
||||
width: 100%
|
||||
align-items: center
|
||||
display: flex
|
||||
justify-content: space-between
|
||||
color: #fff
|
||||
overflow: hidden
|
||||
user-select: none
|
||||
|
||||
.internal-dates-holder
|
||||
position: relative
|
||||
width: 100%
|
||||
display: inline-flex
|
||||
flex: 1 1 100%
|
||||
flex-direction: row
|
||||
justify-content: space-between
|
||||
overflow: hidden
|
||||
user-select: none
|
||||
|
||||
.direction-button
|
||||
background: white
|
||||
color: $primary
|
||||
width: 40px
|
||||
max-width: 50px !important
|
||||
|
||||
.direction-button__left
|
||||
&:before
|
||||
content: '<'
|
||||
display: inline-flex
|
||||
flex-direction: column
|
||||
justify-content: center
|
||||
height: 100%
|
||||
font-weight: 900
|
||||
font-size: 3em
|
||||
|
||||
.direction-button__right
|
||||
&:before
|
||||
content: '>'
|
||||
display: inline-flex
|
||||
flex-direction: column
|
||||
justify-content: center
|
||||
height: 100%
|
||||
font-weight: 900
|
||||
font-size: 3em
|
||||
|
||||
.date-button
|
||||
color: $primary
|
||||
background: white
|
||||
z-index: 2
|
||||
height: 100%
|
||||
outline: 0
|
||||
cursor: pointer
|
||||
border-radius: 3px
|
||||
display: inline-flex
|
||||
flex: 1 0 auto
|
||||
flex-direction: column
|
||||
align-items: stretch
|
||||
position: relative
|
||||
border: 0
|
||||
vertical-align: middle
|
||||
padding: 0
|
||||
font-size: 14px
|
||||
line-height: 1.715em
|
||||
text-decoration: none
|
||||
font-weight: 500
|
||||
text-transform: uppercase
|
||||
text-align: center
|
||||
user-select: none
|
||||
|
||||
.selected-date-button
|
||||
color: white !important
|
||||
background: $primary !important
|
||||
</style>
|
||||
24
src/components/task/TaskCardComponent.vue
Normal file
24
src/components/task/TaskCardComponent.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<q-card>
|
||||
<q-item-section>
|
||||
<q-item-label overline>{{ task.title }}</q-item-label>
|
||||
<q-item-label caption lines="2">{{ task.description }} </q-item-label>
|
||||
<q-item-label caption>Due: {{ task.due_date }}</q-item-label>
|
||||
</q-item-section>
|
||||
<q-expansion-item
|
||||
v-if="task.subtasks && task.subtasks.length"
|
||||
expand-separator
|
||||
label="Subtasks"
|
||||
default-opened
|
||||
>
|
||||
<TaskListComponent :tasks="task.subtasks" />
|
||||
</q-expansion-item>
|
||||
</q-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { defineProps } from 'vue';
|
||||
import type { Task } from 'src/stores/task';
|
||||
|
||||
defineProps<{ task: Task }>();
|
||||
</script>
|
||||
269
src/components/task/TaskEditComponent.vue
Normal file
269
src/components/task/TaskEditComponent.vue
Normal 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>
|
||||
16
src/components/task/TaskListComponent.vue
Normal file
16
src/components/task/TaskListComponent.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<div class="q-pa-md" style="max-width: 350px">
|
||||
<q-list>
|
||||
<div v-for="task in tasks" :key="task.id">
|
||||
<TaskCardComponent :task="task" />
|
||||
</div>
|
||||
</q-list>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Task } from 'src/stores/task';
|
||||
import TaskCardComponent from './TaskCardComponent.vue';
|
||||
|
||||
defineProps<{ tasks: Task[] }>();
|
||||
</script>
|
||||
367
src/components/task/TaskTableComponent.vue
Normal file
367
src/components/task/TaskTableComponent.vue
Normal file
@@ -0,0 +1,367 @@
|
||||
<template>
|
||||
<div class="q-pa-sm">
|
||||
<q-table
|
||||
:rows="tasks"
|
||||
:columns="columns"
|
||||
:grid="$q.screen.xs"
|
||||
dense
|
||||
row-key="$id"
|
||||
flatten
|
||||
no-data-label="I didn't find anything for you"
|
||||
no-results-label="The filter didn't uncover any results"
|
||||
selection="multiple"
|
||||
v-model:selected="selected"
|
||||
:filter="searchFilter"
|
||||
:filter-method="filterRows"
|
||||
>
|
||||
<template v-slot:top>
|
||||
<q-select
|
||||
style="width: 250px"
|
||||
multiple
|
||||
use-chips
|
||||
clearable
|
||||
label="Skills Filter"
|
||||
input-debounce="250"
|
||||
:options="skillTagOptions"
|
||||
v-model="searchFilter.skillTags"
|
||||
option-label="name"
|
||||
option-value="$id"
|
||||
>
|
||||
</q-select>
|
||||
<q-select
|
||||
style="width: 250px"
|
||||
multiple
|
||||
use-chips
|
||||
clearable
|
||||
label="Tag Filter"
|
||||
input-debounce="250"
|
||||
:options="taskTagOptions"
|
||||
v-model="searchFilter.taskTags"
|
||||
option-label="name"
|
||||
option-value="$id"
|
||||
>
|
||||
<template v-slot: prepend>
|
||||
<q-icon name="wrench"></q-icon>
|
||||
</template>
|
||||
</q-select>
|
||||
<q-space />
|
||||
<q-input
|
||||
flatten
|
||||
debounce="300"
|
||||
color="primary"
|
||||
clearable
|
||||
v-model="searchFilter.title"
|
||||
>
|
||||
<template v-slot:append>
|
||||
<q-icon name="search" />
|
||||
</template>
|
||||
</q-input>
|
||||
</template>
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th key="desc" auto-width>
|
||||
<q-checkbox dense v-model="props.selected"></q-checkbox>
|
||||
</q-th>
|
||||
<q-th v-for="col in props.cols" :key="col.name" :props="props">
|
||||
{{ col.label }}
|
||||
</q-th>
|
||||
</q-tr>
|
||||
</template>
|
||||
|
||||
<template v-slot:body-cell-skills="props">
|
||||
<q-td :props="props" class="q-gutter-sm">
|
||||
<q-badge
|
||||
v-for="skill in props.value"
|
||||
:key="skill"
|
||||
:color="skill.tagColour"
|
||||
text-color="white"
|
||||
:label="skill.name"
|
||||
/>
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
<template v-slot:body-cell-tags="props">
|
||||
<q-td :props="props" class="q-gutter-sm">
|
||||
<q-badge
|
||||
v-for="tag in props.value"
|
||||
:key="tag"
|
||||
:color="tag.colour"
|
||||
text-color="white"
|
||||
:label="tag.name"
|
||||
/>
|
||||
</q-td>
|
||||
</template>
|
||||
<template v-slot:body-cell-actions="props">
|
||||
<q-td :props="props" class="q-gutter-sm">
|
||||
<q-btn
|
||||
label="Sign Up"
|
||||
size="sm"
|
||||
color="primary"
|
||||
:to="{ name: 'signup-task', params: { id: props.value } }"
|
||||
/>
|
||||
<q-btn
|
||||
label="Edit"
|
||||
size="sm"
|
||||
color="primary"
|
||||
:to="{ name: 'edit-task', params: { id: props.value } }"
|
||||
/>
|
||||
</q-td>
|
||||
</template>
|
||||
<template v-slot:item="props">
|
||||
<div
|
||||
class="q-pa-xs col-xs-12 col-sm-6 col-md-4 col-lg-3 grid-style-transition"
|
||||
:style="props.selected ? 'transform: scale(0.95);' : ''"
|
||||
>
|
||||
<q-card
|
||||
bordered
|
||||
flat
|
||||
:class="
|
||||
props.selected
|
||||
? $q.dark.isActive
|
||||
? 'bg-grey-9'
|
||||
: 'bg-grey-2'
|
||||
: ''
|
||||
"
|
||||
>
|
||||
<q-card-section>
|
||||
<q-checkbox
|
||||
dense
|
||||
v-model="props.selected"
|
||||
:label="props.row.name"
|
||||
/>
|
||||
</q-card-section>
|
||||
<q-separator />
|
||||
<q-list dense>
|
||||
<q-item
|
||||
v-for="col in props.cols.filter((col:Boat) => col.name !== 'desc')"
|
||||
:key="col.name"
|
||||
>
|
||||
<q-item-section>
|
||||
<q-item-label>{{ col.label }}</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<q-item-label caption v-if="col.name === 'skills'">
|
||||
<q-chip
|
||||
size="sm"
|
||||
v-for="skill in col.value"
|
||||
outline
|
||||
color="primary"
|
||||
:key="skill.$id"
|
||||
>{{ skill.name }}</q-chip
|
||||
></q-item-label
|
||||
>
|
||||
<q-item-label caption v-else-if="col.name === 'tags'">
|
||||
<q-chip
|
||||
size="sm"
|
||||
v-for="tag in col.value"
|
||||
outline
|
||||
color="primary"
|
||||
:key="tag.$id"
|
||||
>{{ tag.name }}</q-chip
|
||||
></q-item-label
|
||||
>
|
||||
<q-item-label caption v-else-if="col.name === 'actions'">
|
||||
<q-btn
|
||||
label="Sign Up"
|
||||
size="sm"
|
||||
color="primary"
|
||||
:to="{ name: 'signup-task', params: { id: col.value } }"
|
||||
/>
|
||||
<q-btn
|
||||
label="Edit"
|
||||
size="sm"
|
||||
color="primary"
|
||||
:to="{ name: 'edit-task', params: { id: col.value } }"
|
||||
/>
|
||||
</q-item-label>
|
||||
<q-item-label v-else caption>{{ col.value }}</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-card>
|
||||
</div>
|
||||
</template>
|
||||
</q-table>
|
||||
<q-page-sticky position="bottom-right" :offset="[18, 18]">
|
||||
<q-fab
|
||||
v-model="fabShow"
|
||||
vertical-actions-align="right"
|
||||
color="primary"
|
||||
glossy
|
||||
icon="keyboard_arrow_up"
|
||||
direction="up"
|
||||
>
|
||||
<q-fab-action
|
||||
color="primary"
|
||||
:disable="loading"
|
||||
label="New Task"
|
||||
to="/task/edit"
|
||||
icon="add"
|
||||
/>
|
||||
<q-fab-action
|
||||
v-if="tasks.length !== 0"
|
||||
class="q-ml-sm"
|
||||
color="primary"
|
||||
:disable="loading"
|
||||
label="Delete task(s)"
|
||||
@click="deleteTasks"
|
||||
icon="delete"
|
||||
/> </q-fab
|
||||
></q-page-sticky>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, defineProps, ref } from 'vue';
|
||||
import { useTaskStore, Task, SkillTag, TaskTag } from 'src/stores/task';
|
||||
import { QTableProps, date, useQuasar } from 'quasar';
|
||||
import { Boat, useBoatStore } from 'src/stores/boat';
|
||||
|
||||
const selected = ref([]);
|
||||
const loading = ref(false); // Placeholder
|
||||
const fabShow = ref(false);
|
||||
const columns = <QTableProps['columns']>[
|
||||
{
|
||||
name: 'title',
|
||||
required: true,
|
||||
label: 'Title',
|
||||
align: 'left',
|
||||
field: 'title',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
name: 'due_date',
|
||||
align: 'left',
|
||||
label: 'Due Date',
|
||||
field: 'due_date',
|
||||
format: (val) => date.formatDate(val, 'MMM DD, YYYY'),
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
align: 'left',
|
||||
label: 'Status',
|
||||
field: 'status',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
name: 'skills',
|
||||
align: 'left',
|
||||
label: 'Skills',
|
||||
field: (row) =>
|
||||
row.required_skills.map((s: string) => taskStore.getSkillById(s)),
|
||||
sortable: false,
|
||||
},
|
||||
{
|
||||
name: 'tags',
|
||||
align: 'left',
|
||||
label: 'Tags',
|
||||
field: (row) => row.tags.map((s: string) => taskStore.getTaskTagById(s)),
|
||||
sortable: false,
|
||||
},
|
||||
{
|
||||
name: 'boat',
|
||||
align: 'left',
|
||||
label: 'Boat',
|
||||
field: (row) =>
|
||||
useBoatStore().boats.find((boat) => boat.$id === row.boat)?.name,
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
name: 'volunteers',
|
||||
align: 'left',
|
||||
label: "People Req'd",
|
||||
field: 'volunteers_required',
|
||||
sortable: false,
|
||||
},
|
||||
{
|
||||
name: 'signedup',
|
||||
align: 'left',
|
||||
label: 'Signed Up',
|
||||
field: (row) => row.volunteers.length,
|
||||
sortable: false,
|
||||
},
|
||||
{
|
||||
name: 'depends',
|
||||
align: 'left',
|
||||
label: 'Dependent Tasks',
|
||||
field: 'depends_on',
|
||||
format: (val) => {
|
||||
return (
|
||||
val
|
||||
.map((t: string) => taskStore.getTaskById(t))
|
||||
.filter((t: Task) => t)
|
||||
.map((t: Task) => t.title)
|
||||
.join(', ') || null
|
||||
);
|
||||
},
|
||||
},
|
||||
{ name: 'actions', align: 'center', label: 'Actions', field: '$id' },
|
||||
];
|
||||
|
||||
defineProps<{ tasks: Task[] }>();
|
||||
const taskStore = useTaskStore();
|
||||
const $q = useQuasar();
|
||||
|
||||
interface SearchObject {
|
||||
title: string;
|
||||
skillTags: SkillTag[];
|
||||
taskTags: TaskTag[];
|
||||
}
|
||||
|
||||
const searchFilter = ref<SearchObject>({
|
||||
title: '',
|
||||
skillTags: [],
|
||||
taskTags: [],
|
||||
});
|
||||
|
||||
const skillTagOptions = ref<SkillTag[]>(taskStore.skillTags);
|
||||
const taskTagOptions = ref<TaskTag[]>(taskStore.taskTags);
|
||||
|
||||
// function onRowClick(evt: Event, row: Task) {
|
||||
// router.push({ name: 'edit-task', params: { id: row.$id } });
|
||||
// }
|
||||
// TODO: Implement server side search
|
||||
const filterRows = computed(
|
||||
() => (rows: readonly Task[], terms: SearchObject) => {
|
||||
return rows
|
||||
.filter((row) =>
|
||||
terms.title
|
||||
? row.title.toLowerCase().includes(terms.title.toLowerCase())
|
||||
: true
|
||||
)
|
||||
.filter((row) =>
|
||||
terms.skillTags && terms.skillTags.length > 0
|
||||
? row.required_skills.some((req_skill) =>
|
||||
terms.skillTags.map((t) => t.$id).includes(req_skill)
|
||||
)
|
||||
: true
|
||||
)
|
||||
.filter((row) =>
|
||||
terms.taskTags && terms.taskTags.length > 0
|
||||
? row.tags.some((tag) =>
|
||||
terms.taskTags.map((t) => t.$id).includes(tag)
|
||||
)
|
||||
: true
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
function deleteTasks() {
|
||||
confirmDelete(selected.value);
|
||||
}
|
||||
function confirmDelete(tasks: Task[]) {
|
||||
$q.dialog({
|
||||
title: 'Confirm',
|
||||
message:
|
||||
'You are about to delete ' + tasks.length + ' tasks. Are you sure?',
|
||||
cancel: true,
|
||||
persistent: true,
|
||||
}).onOk(() => {
|
||||
selected.value.map((task: Task) => {
|
||||
taskStore.deleteTask(task);
|
||||
return;
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@@ -11,5 +11,7 @@ import { ref } from 'vue';
|
||||
import { useBoatStore } from 'src/stores/boat';
|
||||
import ToolbarComponent from 'src/components/ToolbarComponent.vue';
|
||||
|
||||
const boatStore = useBoatStore();
|
||||
boatStore.fetchBoats();
|
||||
const boats = ref(useBoatStore().boats);
|
||||
</script>
|
||||
|
||||
@@ -8,32 +8,25 @@
|
||||
<q-avatar icon="person" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
Ricky Gervais
|
||||
{{ authStore.currentUser?.name }}
|
||||
<q-item-label caption>Name</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item>
|
||||
<q-item-section avatar>
|
||||
<q-avatar icon="numbers" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
12345
|
||||
<q-item-label caption>Member ID</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-separator />
|
||||
<q-item>
|
||||
<q-item-section>
|
||||
<q-item-label overline>Certifications</q-item-label>
|
||||
<q-chip square icon="verified" color="primary" text-color="white"
|
||||
>J/27</q-chip
|
||||
>
|
||||
<q-chip square icon="verified" color="green" text-color="white"
|
||||
>Capri25</q-chip
|
||||
>
|
||||
<q-chip square icon="verified" color="grey-8" text-color="white"
|
||||
>Night</q-chip
|
||||
>
|
||||
<div>
|
||||
<q-chip square icon="verified" color="green" text-color="white"
|
||||
>J/27</q-chip
|
||||
>
|
||||
<q-chip square icon="verified" color="blue" text-color="white"
|
||||
>Capri25</q-chip
|
||||
>
|
||||
<q-chip square icon="verified" color="grey-9" text-color="white"
|
||||
>Night</q-chip
|
||||
>
|
||||
</div>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
@@ -42,4 +35,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import ToolbarComponent from 'src/components/ToolbarComponent.vue';
|
||||
import { useAuthStore } from 'src/stores/auth';
|
||||
|
||||
const authStore = useAuthStore();
|
||||
</script>
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
<template>
|
||||
<toolbar-component pageTitle="Tasks" />
|
||||
<q-page padding>
|
||||
<q-card bordered separator class="mobile-card">
|
||||
<q-card-section clickable v-ripple>
|
||||
<div class="text-h6">Launch Prep</div>
|
||||
<div class="text-subtitle2">Prepare for Launch</div>
|
||||
<q-chip size="md" color="green" text-color="white" icon="alarm">
|
||||
APR 1,2024
|
||||
</q-chip>
|
||||
<q-chip size="md" icon="build"> 24 tasks </q-chip>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
<q-card bordered separator class="mobile-card">
|
||||
<q-card-section clickable v-ripple>
|
||||
<div class="text-h6">General Maintenance</div>
|
||||
<div class="text-subtitle2">Day to day maintenance and upkeep</div>
|
||||
<q-chip size="md" icon="build"> 4 tasks </q-chip>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ToolbarComponent from 'src/components/ToolbarComponent.vue';
|
||||
</script>
|
||||
8
src/pages/admin/TaskAdminPage.vue
Normal file
8
src/pages/admin/TaskAdminPage.vue
Normal file
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<q-page padding>
|
||||
<!-- content -->
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
@@ -1,38 +1,36 @@
|
||||
<template>
|
||||
<q-page padding>
|
||||
<q-page>
|
||||
<q-list>
|
||||
<q-form @submit="onSubmit" @reset="onReset" class="q-gutter-md">
|
||||
<q-input
|
||||
bottom-slots
|
||||
v-model="bookingForm.name"
|
||||
label="Creating reservation for"
|
||||
readonly
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<q-icon name="person" />
|
||||
</template>
|
||||
</q-input>
|
||||
<q-form @submit="onSubmit" @reset="onReset" class="q-gutter-sm">
|
||||
<q-item>
|
||||
<q-item-section :avatar="true">
|
||||
<q-icon name="person"
|
||||
/></q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label> Name: {{ bookingForm.name }} </q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-expansion-item
|
||||
expand-separator
|
||||
v-model="resourceView"
|
||||
icon="calendar_month"
|
||||
label="Boat and Time"
|
||||
default-opened
|
||||
class="q-mt-none"
|
||||
:caption="bookingSummary"
|
||||
>
|
||||
<q-separator />
|
||||
<resource-schedule-viewer-component
|
||||
@on-click-time="onClickTime"
|
||||
@on-update-duration="
|
||||
(value) => {
|
||||
bookingForm.duration = value;
|
||||
}
|
||||
"
|
||||
/>
|
||||
<q-banner :class="$q.dark.isActive ? 'bg-grey-9' : 'bg-grey-3'">
|
||||
Use the calendar to pick a date. Select an available boat and
|
||||
timeslot below.
|
||||
</q-banner>
|
||||
<BoatScheduleTableComponent v-model="timeblock" />
|
||||
|
||||
<q-banner
|
||||
rounded
|
||||
class="bg-warning text-grey-10"
|
||||
v-if="bookingForm.boat?.defects"
|
||||
style="max-width: 95vw; margin: auto"
|
||||
v-if="bookingForm.boat && bookingForm.boat.defects.length > 0"
|
||||
>
|
||||
<template v-slot:avatar>
|
||||
<q-icon name="warning" color="grey-10" />
|
||||
@@ -62,7 +60,32 @@
|
||||
icon="people"
|
||||
label="Crew and Passengers"
|
||||
default-opened
|
||||
>
|
||||
><q-banner v-if="bookingForm.boat"
|
||||
>Passengers:
|
||||
{{ bookingForm.members.length + bookingForm.guests.length }} /
|
||||
{{ bookingForm.boat.maxPassengers }}</q-banner
|
||||
>
|
||||
<q-item
|
||||
class="q-my-sm"
|
||||
v-for="passenger in [...bookingForm.members, ...bookingForm.guests]"
|
||||
:key="passenger.name"
|
||||
>
|
||||
<q-item-section avatar>
|
||||
<q-avatar color="primary" text-color="white" size="sm">
|
||||
{{
|
||||
passenger.name
|
||||
.split(' ')
|
||||
.map((i) => i.charAt(0))
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
}}
|
||||
</q-avatar>
|
||||
</q-item-section>
|
||||
<q-item-section>{{ passenger.name }}</q-item-section>
|
||||
<q-item-section side>
|
||||
<q-btn color="negative" flat dense round icon="cancel" />
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-separator />
|
||||
</q-expansion-item>
|
||||
|
||||
@@ -74,50 +97,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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
297
src/pages/schedule/ManageCalendar.vue
Normal file
297
src/pages/schedule/ManageCalendar.vue
Normal 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>
|
||||
@@ -3,7 +3,7 @@
|
||||
<q-item v-for="link in navlinks" :key="link.label">
|
||||
<q-btn
|
||||
:icon="link.icon"
|
||||
color="primary"
|
||||
:color="link.color"
|
||||
size="1.25em"
|
||||
:to="link.to"
|
||||
:label="link.label"
|
||||
@@ -21,7 +21,19 @@ const navlinks = [
|
||||
icon: 'more_time',
|
||||
to: '/schedule/book',
|
||||
label: 'Create a Reservation',
|
||||
color: 'primary',
|
||||
},
|
||||
{
|
||||
icon: 'calendar_month',
|
||||
to: '/schedule/view',
|
||||
label: 'View Schedule',
|
||||
color: 'primary',
|
||||
},
|
||||
{
|
||||
icon: 'edit_calendar',
|
||||
to: '/schedule/manage',
|
||||
label: 'Manage Calendar',
|
||||
color: 'accent',
|
||||
},
|
||||
{ icon: 'calendar_month', to: '/schedule/view', label: 'View Schedule' },
|
||||
];
|
||||
</script>
|
||||
|
||||
16
src/pages/task/TaskEditPage.vue
Normal file
16
src/pages/task/TaskEditPage.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<ToolbarComponent pageTitle="Tasks" />
|
||||
<q-page padding>
|
||||
<div class="q-pa-md" style="max-width: 400px">
|
||||
<TaskEditComponent :taskId="taskId" />
|
||||
</div>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const taskId = useRoute().params.id as string;
|
||||
console.log(taskId);
|
||||
import ToolbarComponent from 'src/components/ToolbarComponent.vue';
|
||||
import TaskEditComponent from 'src/components/task/TaskEditComponent.vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
</script>
|
||||
16
src/pages/task/TaskPage.vue
Normal file
16
src/pages/task/TaskPage.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<toolbar-component pageTitle="Tasks" />
|
||||
<q-page padding>
|
||||
<TaskTableComponent :tasks="taskStore.tasks" />
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTaskStore } from 'stores/task';
|
||||
import ToolbarComponent from 'src/components/ToolbarComponent.vue';
|
||||
import TaskTableComponent from 'src/components/task/TaskTableComponent.vue';
|
||||
|
||||
const taskStore = useTaskStore();
|
||||
|
||||
taskStore.fetchTasks(); // Fetch on mount
|
||||
</script>
|
||||
@@ -38,9 +38,6 @@ export default route(function (/* { store, ssrContext } */) {
|
||||
Router.beforeEach((to) => {
|
||||
const auth = useAuthStore();
|
||||
|
||||
if (!auth.ready) {
|
||||
return false;
|
||||
}
|
||||
if (auth.currentUser) {
|
||||
return to.meta.accountRoute ? { name: 'index' } : true;
|
||||
} else {
|
||||
|
||||
@@ -1,24 +1,9 @@
|
||||
import ScheduleIndexPage from 'pages/schedule/ScheduleIndexPage.vue';
|
||||
import ChecklistPageVue from 'pages/ChecklistPage.vue';
|
||||
import LoginPageVue from 'pages/LoginPage.vue';
|
||||
import ReferencePageVue from 'src/pages/reference/ReferencePage.vue';
|
||||
import ReferenceIndexPageVue from 'src/pages/reference/ReferenceIndexPage.vue';
|
||||
import ReferenceItemPageVue from 'src/pages/reference/ReferenceItemPage.vue';
|
||||
import MainLayoutVue from 'src/layouts/MainLayout.vue';
|
||||
import BoatPageVue from 'src/pages/BoatPage.vue';
|
||||
import CertificationPageVue from 'src/pages/CertificationPage.vue';
|
||||
import IndexPageVue from 'src/pages/IndexPage.vue';
|
||||
import ProfilePageVue from 'src/pages/ProfilePage.vue';
|
||||
import TaskPageVue from 'src/pages/TaskPage.vue';
|
||||
import { RouteRecordRaw } from 'vue-router';
|
||||
import SchedulePageView from 'pages/schedule/SchedulePageView.vue';
|
||||
import BoatReservationPageVue from 'src/pages/schedule/BoatReservationPage.vue';
|
||||
import BoatScheduleViewVue from 'src/pages/schedule/BoatScheduleView.vue';
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
component: MainLayoutVue,
|
||||
component: () => import('src/layouts/MainLayout.vue'),
|
||||
// If we get so big we need lazy loading, we can use imports again
|
||||
// component: () => import('layouts/MainLayout.vue'),
|
||||
children: [
|
||||
@@ -26,69 +11,89 @@ const routes: RouteRecordRaw[] = [
|
||||
path: '',
|
||||
// If we get so big we need lazy loading, we can use imports again
|
||||
// component: () => import('pages/IndexPage.vue'),
|
||||
component: IndexPageVue,
|
||||
component: () => import('src/pages/IndexPage.vue'),
|
||||
name: 'index',
|
||||
},
|
||||
{
|
||||
path: '/boat',
|
||||
component: BoatPageVue,
|
||||
component: () => import('src/pages/BoatPage.vue'),
|
||||
name: 'boat',
|
||||
},
|
||||
{
|
||||
path: '/schedule',
|
||||
component: SchedulePageView,
|
||||
component: () => import('pages/schedule/SchedulePageView.vue'),
|
||||
name: 'schedule',
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
component: ScheduleIndexPage,
|
||||
component: () => import('pages/schedule/ScheduleIndexPage.vue'),
|
||||
name: 'schedule-index',
|
||||
},
|
||||
{
|
||||
path: 'book',
|
||||
component: BoatReservationPageVue,
|
||||
component: () =>
|
||||
import('src/pages/schedule/BoatReservationPage.vue'),
|
||||
name: 'reserve-boat',
|
||||
},
|
||||
{
|
||||
path: 'view',
|
||||
component: BoatScheduleViewVue,
|
||||
component: () => import('src/pages/schedule/BoatScheduleView.vue'),
|
||||
name: 'boat-schedule',
|
||||
},
|
||||
{
|
||||
path: 'manage',
|
||||
component: () => import('src/pages/schedule/ManageCalendar.vue'),
|
||||
name: 'manage-schedule',
|
||||
meta: { requiresScheduleAdmin: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/certification',
|
||||
component: CertificationPageVue,
|
||||
component: () => import('src/pages/CertificationPage.vue'),
|
||||
name: 'certification',
|
||||
},
|
||||
{
|
||||
path: '/task',
|
||||
component: TaskPageVue,
|
||||
name: 'task',
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
component: () => import('src/pages/task/TaskPage.vue'),
|
||||
name: 'task-index',
|
||||
},
|
||||
{
|
||||
path: '/:id/edit',
|
||||
component: () => import('pages/task/TaskEditPage.vue'),
|
||||
name: 'edit-task',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/checklist',
|
||||
component: ChecklistPageVue,
|
||||
component: () => import('pages/ChecklistPage.vue'),
|
||||
name: 'checklist',
|
||||
},
|
||||
{
|
||||
path: '/profile',
|
||||
component: ProfilePageVue,
|
||||
component: () => import('src/pages/ProfilePage.vue'),
|
||||
name: 'profile',
|
||||
},
|
||||
{
|
||||
path: '/reference',
|
||||
component: ReferencePageVue,
|
||||
component: () => import('src/pages/reference/ReferencePage.vue'),
|
||||
name: 'reference',
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
component: ReferenceIndexPageVue,
|
||||
component: () =>
|
||||
import('src/pages/reference/ReferenceIndexPage.vue'),
|
||||
name: 'reference-index',
|
||||
},
|
||||
{
|
||||
path: '/reference/:id/view',
|
||||
component: ReferenceItemPageVue,
|
||||
component: () =>
|
||||
import('src/pages/reference/ReferenceItemPage.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -97,6 +102,7 @@ const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/admin',
|
||||
component: () => import('layouts/AdminLayout.vue'),
|
||||
meta: { requiresAdmin: true },
|
||||
children: [
|
||||
{
|
||||
path: '/user',
|
||||
@@ -112,7 +118,7 @@ const routes: RouteRecordRaw[] = [
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
component: LoginPageVue,
|
||||
component: () => import('pages/LoginPage.vue'),
|
||||
name: 'login',
|
||||
meta: {
|
||||
publicRoute: true,
|
||||
|
||||
@@ -1,11 +1,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 };
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
65
src/stores/sampledata/boat.ts
Normal file
65
src/stores/sampledata/boat.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
export const getSampleData = () => [
|
||||
{
|
||||
$id: '1',
|
||||
name: 'ProjectX',
|
||||
displayName: 'PX',
|
||||
class: 'J/27',
|
||||
year: 1981,
|
||||
imgSrc: '/tmpimg/j27.png',
|
||||
iconSrc: '/tmpimg/projectx_avatar256.png',
|
||||
bookingAvailable: true,
|
||||
maxPassengers: 8,
|
||||
requiredCerts: [],
|
||||
defects: [
|
||||
{
|
||||
type: 'engine',
|
||||
severity: 'moderate',
|
||||
description: 'Fuel line leaks at engine fitting.',
|
||||
detail: `The gasket in the end of the fuel hose is damaged, and does not properly seal.
|
||||
This will cause fuel to leak, and will allow air into the fuel chamber, causing a lean mixture,
|
||||
and rough engine performance.`,
|
||||
},
|
||||
{
|
||||
type: 'rigging',
|
||||
severity: 'moderate',
|
||||
description: 'Tiller extension is broken.',
|
||||
detail:
|
||||
'The tiller extension swivel is broken, and will not attach to the tiller.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
$id: '2',
|
||||
name: 'Take5',
|
||||
displayName: 'T5',
|
||||
class: 'J/27',
|
||||
year: 1985,
|
||||
imgSrc: '/tmpimg/j27.png',
|
||||
iconsrc: '/tmpimg/take5_avatar32.png',
|
||||
bookingAvailable: true,
|
||||
maxPassengers: 8,
|
||||
requiredCerts: [],
|
||||
},
|
||||
{
|
||||
$id: '3',
|
||||
name: 'WeeBeestie',
|
||||
displayName: 'WB',
|
||||
class: 'Capri 25',
|
||||
year: 1989,
|
||||
imgSrc: '/tmpimg/capri25.png',
|
||||
bookingAvailable: true,
|
||||
maxPassengers: 6,
|
||||
requiredCerts: [],
|
||||
},
|
||||
{
|
||||
$id: '4',
|
||||
name: 'Just My Imagination',
|
||||
displayName: 'JMI',
|
||||
class: 'Sirius 28',
|
||||
year: 1989,
|
||||
imgSrc: '/tmpimg/JMI.jpg',
|
||||
bookingAvailable: true,
|
||||
maxPassengers: 8,
|
||||
requiredCerts: [],
|
||||
},
|
||||
];
|
||||
141
src/stores/sampledata/schedule.ts
Normal file
141
src/stores/sampledata/schedule.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
31
src/stores/schedule.types.ts
Normal file
31
src/stores/schedule.types.ts
Normal 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
158
src/stores/task.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { AppwriteIds, databases, ID } from 'src/boot/appwrite';
|
||||
import type { Models } from 'appwrite';
|
||||
|
||||
export const TASKSTATUS = ['ready', 'complete', 'waiting', 'archived'];
|
||||
|
||||
export interface Task extends Partial<Models.Document> {
|
||||
title: string;
|
||||
description: string;
|
||||
/* Array of Appwrite Document IDs */
|
||||
required_skills: string[];
|
||||
/* Array of Appwrite Document IDs */
|
||||
tags: string[];
|
||||
due_date: string;
|
||||
duration: number;
|
||||
/* Array of Appwrite Document IDs */
|
||||
volunteers: string[];
|
||||
volunteers_required: number;
|
||||
status: string;
|
||||
/* Array of Appwrite Document IDs */
|
||||
depends_on: string[];
|
||||
/* Appwrite ID of a Boat resource */
|
||||
boat?: string[];
|
||||
}
|
||||
|
||||
export interface TaskTag extends Models.Document {
|
||||
name: string;
|
||||
description: string;
|
||||
colour: string;
|
||||
}
|
||||
export interface SkillTag extends Models.Document {
|
||||
name: string;
|
||||
description: string;
|
||||
tagColour: string;
|
||||
}
|
||||
|
||||
export const useTaskStore = defineStore('tasks', {
|
||||
state: () => ({
|
||||
tasks: [] as Task[],
|
||||
taskTags: [] as TaskTag[],
|
||||
skillTags: [] as SkillTag[],
|
||||
}),
|
||||
|
||||
actions: {
|
||||
async fetchTasks() {
|
||||
try {
|
||||
await this.fetchTaskTags();
|
||||
await this.fetchSkillTags();
|
||||
const response = await databases.listDocuments(
|
||||
AppwriteIds.databaseId,
|
||||
AppwriteIds.collection.task
|
||||
);
|
||||
this.tasks = response.documents as Task[];
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch tasks', error);
|
||||
}
|
||||
},
|
||||
async fetchTaskTags() {
|
||||
// This is fine for a small number of tags, but more than a few hundred tags, we'd need to optimize
|
||||
try {
|
||||
const response = await databases.listDocuments(
|
||||
AppwriteIds.databaseId,
|
||||
AppwriteIds.collection.taskTags
|
||||
);
|
||||
this.taskTags = response.documents as TaskTag[];
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch task tags', error);
|
||||
}
|
||||
},
|
||||
async fetchSkillTags() {
|
||||
// This is fine for a small number of tags, but more than a few hundred tags, we'd need to optimize
|
||||
try {
|
||||
const response = await databases.listDocuments(
|
||||
AppwriteIds.databaseId,
|
||||
AppwriteIds.collection.skillTags
|
||||
);
|
||||
this.skillTags = response.documents as SkillTag[];
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch skill tags', error);
|
||||
}
|
||||
},
|
||||
async deleteTask(task: Task | string) {
|
||||
const docId = typeof task === 'string' ? task : task.$id;
|
||||
if (docId === undefined) {
|
||||
console.error('No document ID provided to deleteTask!');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await databases.deleteDocument(
|
||||
AppwriteIds.databaseId,
|
||||
AppwriteIds.collection.task,
|
||||
docId
|
||||
);
|
||||
this.tasks = this.tasks.filter((task) => docId !== task.$id);
|
||||
} catch (error) {
|
||||
// Need some better error handling, here.
|
||||
console.error('Failed to delete task:', error);
|
||||
}
|
||||
},
|
||||
async addTask(task: Task) {
|
||||
const newTask = <Models.Document>{ ...task };
|
||||
try {
|
||||
const response = await databases.createDocument(
|
||||
AppwriteIds.databaseId,
|
||||
AppwriteIds.collection.task,
|
||||
ID.unique(),
|
||||
newTask
|
||||
);
|
||||
this.tasks.push(response as Task);
|
||||
} catch (error) {
|
||||
console.error('Failed to add task:', error);
|
||||
}
|
||||
},
|
||||
async updateTask(task: Task) {
|
||||
const newTask = <Partial<Models.Document>>{
|
||||
...task,
|
||||
id: undefined,
|
||||
$databaseId: undefined,
|
||||
$collectionId: undefined,
|
||||
};
|
||||
if (!task.$id) {
|
||||
console.error('No Task ID!');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await databases.updateDocument(
|
||||
AppwriteIds.databaseId,
|
||||
AppwriteIds.collection.task,
|
||||
task.$id,
|
||||
newTask
|
||||
);
|
||||
this.tasks.push(response as Task);
|
||||
} catch (error) {
|
||||
console.error('Failed to update task:', error);
|
||||
}
|
||||
},
|
||||
// TODO: Enhance this store to include offline caching, and subscription notification when items change on the server.
|
||||
},
|
||||
// Add more actions as needed (e.g., updateTask, deleteTask)
|
||||
getters: {
|
||||
getTaskById: (state) => (id: string) => {
|
||||
return state.tasks.find((task) => task.$id === id) || null;
|
||||
},
|
||||
getTaskTagById: (state) => (id: string) => {
|
||||
return state.taskTags.find((tag) => tag.$id === id) || null;
|
||||
},
|
||||
getSkillById: (state) => (id: string) => {
|
||||
return state.skillTags.find((tag) => tag.$id === id) || null;
|
||||
},
|
||||
filterTasksByTitle: (state) => (searchQuery: string) => {
|
||||
const result = state.tasks.filter((task) =>
|
||||
task.title.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
console.log(result);
|
||||
return result;
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -3,4 +3,4 @@
|
||||
"compilerOptions": {
|
||||
"baseUrl": "."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
6
tsconfig.vue-tsc.json
Normal file
6
tsconfig.vue-tsc.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"skipLibCheck": true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user