Compare commits
66 Commits
v0.0.2
...
de04b53914
| Author | SHA1 | Date | |
|---|---|---|---|
|
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 }}" }'
|
||||
|
||||
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
|
||||
19
package.json
19
package.json
@@ -13,24 +13,26 @@
|
||||
"build": "quasar build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@quasar/extras": "^1.16.4",
|
||||
"@quasar/extras": "^1.16.11",
|
||||
"@quasar/quasar-ui-qcalendar": "^4.0.0-beta.19",
|
||||
"appwrite": "^13.0.0",
|
||||
"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.7.4",
|
||||
"@quasar/quasar-app-extension-qcalendar": "^4.0.0-beta.19",
|
||||
"@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",
|
||||
"quasar": "^2.15.2",
|
||||
"typescript": "^4.5.4",
|
||||
"workbox-build": "^7.0.0",
|
||||
"workbox-cacheable-response": "^7.0.0",
|
||||
@@ -38,10 +40,11 @@
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js
|
||||
|
||||
const { configure } = require('quasar/wrappers');
|
||||
const path = require('path');
|
||||
|
||||
module.exports = configure(function (/* ctx */) {
|
||||
return {
|
||||
@@ -48,6 +49,7 @@ 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',
|
||||
@@ -83,6 +85,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,
|
||||
|
||||
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 |
@@ -14,10 +14,16 @@ const client = new Client();
|
||||
|
||||
// Private self-hosted appwrite
|
||||
client
|
||||
.setEndpoint('https://apidev.bab.toal.ca/v1')
|
||||
.setProject('655a7116479b4d5a815f');
|
||||
//TODO
|
||||
const appDatabaseId = '';
|
||||
.setEndpoint(process.env.APPWRITE_API_ENDPOINT)
|
||||
.setProject(process.env.APPWRITE_API_PROJECT);
|
||||
//TODO move this to config file
|
||||
|
||||
const AppwriteIds = {
|
||||
databaseId: '65ee1cbf9c2493faf15f',
|
||||
collectionIdTask: '65ee1cd5b550023fae4f',
|
||||
collectionIdTaskTags: '65ee21d72d5c8007c34c',
|
||||
collectionIdSkillTags: '66072582a74d94a4bd01',
|
||||
};
|
||||
|
||||
const account = new Account(client);
|
||||
const databases = new Databases(client);
|
||||
@@ -86,4 +92,4 @@ function login(email: string, password: string) {
|
||||
});
|
||||
});
|
||||
}
|
||||
export { client, account, databases, ID, appDatabaseId, login, logout };
|
||||
export { client, account, databases, ID, AppwriteIds, login, logout };
|
||||
|
||||
@@ -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,6 +116,17 @@ 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';
|
||||
|
||||
type EventData = {
|
||||
event: object;
|
||||
scope: {
|
||||
timestamp: object;
|
||||
columnindex: number;
|
||||
activeDate: boolean;
|
||||
droppable: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
const durations = [1, 1.5, 2, 2.5, 3, 3.5, 4];
|
||||
|
||||
@@ -159,8 +172,8 @@ function monthFormatter() {
|
||||
|
||||
function getEvents(scope: ResourceIntervalScope) {
|
||||
const resourceEvents = scheduleStore.getBoatReservations(
|
||||
scope.resource.id,
|
||||
date.extractDate(selectedDate.value, 'YYYY-MM-DD')
|
||||
date.extractDate(selectedDate.value, 'YYYY-MM-DD'),
|
||||
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
|
||||
|
||||
110
src/components/scheduling/BoatSelection.vue
Normal file
110
src/components/scheduling/BoatSelection.vue
Normal file
@@ -0,0 +1,110 @@
|
||||
<template>
|
||||
<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 />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import {
|
||||
today,
|
||||
parseTimestamp,
|
||||
addToDate,
|
||||
Timestamp,
|
||||
} from '@quasar/quasar-ui-qcalendar';
|
||||
import { Boat, useBoatStore } from 'src/stores/boat';
|
||||
import { useScheduleStore, Timeblock } from 'src/stores/schedule';
|
||||
import { computed } from 'vue';
|
||||
import { date } from 'quasar';
|
||||
import BoatScheduleTableComponent from './boat/BoatScheduleTableComponent.vue';
|
||||
|
||||
type EventData = {
|
||||
event: object;
|
||||
scope: {
|
||||
timestamp: object;
|
||||
columnindex: number;
|
||||
activeDate: boolean;
|
||||
droppable: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
const calendar = ref();
|
||||
const scheduleStore = useScheduleStore();
|
||||
const selectedDate = ref(today());
|
||||
const selectedBoatTime = ref();
|
||||
|
||||
const disabledBefore = computed(() => {
|
||||
const todayTs = parseTimestamp(today()) as Timestamp;
|
||||
return addToDate(todayTs, { day: -1 }).date;
|
||||
});
|
||||
|
||||
function monthFormatter() {
|
||||
try {
|
||||
return new Intl.DateTimeFormat('en-CA' || undefined, {
|
||||
month: 'long',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
} catch (e) {
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
const disabledDays = () => {
|
||||
// Placeholder. This should actually compute days when boats aren't available.
|
||||
const days = [];
|
||||
const todayTs = parseTimestamp(today()) as Timestamp;
|
||||
days.push(addToDate(todayTs, { day: 2 }).date);
|
||||
return days;
|
||||
};
|
||||
|
||||
const boatoptions = (boat: Boat) => {
|
||||
const options = useScheduleStore()
|
||||
.getTimeblocksForDate(date.extractDate(selectedDate.value, 'YYYY-MM-DD'))
|
||||
.map((x: Timeblock) => {
|
||||
const conflicts = getConflicts(x, boat);
|
||||
return {
|
||||
label: x.start.time + ' to ' + x.end.time,
|
||||
value: boat.id + ':' + x.start.time,
|
||||
disable: conflicts.length > 0,
|
||||
user: conflicts[0]?.user,
|
||||
boat: boat,
|
||||
timeblock: x,
|
||||
};
|
||||
});
|
||||
return options;
|
||||
};
|
||||
|
||||
const emit = defineEmits(['onClickTime', 'onUpdateDuration']);
|
||||
|
||||
function onPrev() {
|
||||
calendar.value.prev();
|
||||
}
|
||||
function onNext() {
|
||||
calendar.value.next();
|
||||
}
|
||||
|
||||
function onClickDate(data: EventData) {
|
||||
return data;
|
||||
}
|
||||
function onChange(data: EventData) {
|
||||
return data;
|
||||
}
|
||||
|
||||
const getConflicts = (timeblock: Timeblock, boat: Boat) => {
|
||||
const start = date.buildDate({
|
||||
hour: timeblock.start.hour,
|
||||
minute: timeblock.start.minute,
|
||||
second: 0,
|
||||
millisecond: 0,
|
||||
});
|
||||
const end = date.buildDate({
|
||||
hour: timeblock.end.hour,
|
||||
minute: timeblock.end.minute,
|
||||
second: 0,
|
||||
millisecond: 0,
|
||||
});
|
||||
return scheduleStore.getConflictingReservations(boat, start, end);
|
||||
};
|
||||
</script>
|
||||
118
src/components/scheduling/boat/BoatScheduleTableComponent.vue
Normal file
118
src/components/scheduling/boat/BoatScheduleTableComponent.vue
Normal file
@@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<div class="q-px-sm">
|
||||
<CalendarHeaderComponent v-model="selectedDate" />
|
||||
<div class="boat-schedule-table-component">
|
||||
<QCalendarDay
|
||||
ref="calendar"
|
||||
class="q-pt-xs"
|
||||
flat
|
||||
animated
|
||||
dense
|
||||
v-model="selectedDate"
|
||||
:column-count="boats.length"
|
||||
@change="scrollToEvent()"
|
||||
>
|
||||
<template #head-day="{ scope }">
|
||||
<div style="text-align: center; font-weight: 800">
|
||||
{{ boats[scope.columnIndex].displayName }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #day-body="{ scope }">
|
||||
<div
|
||||
v-for="block in scheduleStore.getTimeblocksForDate(scope.timestamp)"
|
||||
:key="block.id"
|
||||
>
|
||||
<div
|
||||
class="timeblock"
|
||||
:style="
|
||||
blockStyles(block, scope.timeStartPos, scope.timeDurationHeight)
|
||||
"
|
||||
@click="selectBlock($event, scope, block)"
|
||||
>
|
||||
Available
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</QCalendarDay>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Timestamp, diffTimestamp, today } from '@quasar/quasar-ui-qcalendar';
|
||||
|
||||
import CalendarHeaderComponent from './CalendarHeaderComponent.vue';
|
||||
|
||||
import { ref } from 'vue';
|
||||
import { useBoatStore } from 'src/stores/boat';
|
||||
import { Timeblock, useScheduleStore } from 'src/stores/schedule';
|
||||
|
||||
const scheduleStore = useScheduleStore();
|
||||
const boatStore = useBoatStore();
|
||||
|
||||
const selectedDate = ref(today());
|
||||
|
||||
const boats = boatStore.boats;
|
||||
|
||||
const calendar = ref<QCalendarDay.QCalendarDay | null>(null);
|
||||
|
||||
function blockStyles(
|
||||
block: Timeblock,
|
||||
timeStartPos: (t: string) => string,
|
||||
timeDurationHeight: (d: number) => string
|
||||
) {
|
||||
const s = {
|
||||
top: '',
|
||||
height: '',
|
||||
};
|
||||
if (timeStartPos && timeDurationHeight) {
|
||||
s.top = timeStartPos(block.start.time) + 'px';
|
||||
s.height =
|
||||
timeDurationHeight(
|
||||
diffTimestamp(block.start, block.end, false) / 1000 / 60
|
||||
) + 'px';
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
interface DayBodyScope {
|
||||
columnIndex: number;
|
||||
timeDurationHeight: string;
|
||||
timeStartPos: (time: string, clamp: boolean) => string;
|
||||
timestamp: Timestamp;
|
||||
}
|
||||
|
||||
function selectBlock(event: MouseEvent, scope: DayBodyScope, block: Timeblock) {
|
||||
const target = event.target as HTMLDivElement;
|
||||
target.classList.add('selected');
|
||||
target.textContent = 'selected';
|
||||
}
|
||||
|
||||
function scrollToEvent() {
|
||||
setTimeout(() => calendar.value?.scrollToTime('09:00'), 0); // Should figure out why we need this setTimeout...
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="sass">
|
||||
.boat-schedule-table-component
|
||||
display: flex
|
||||
height: 40vh
|
||||
.timeblock
|
||||
display: flex
|
||||
position: absolute
|
||||
justify-content: center
|
||||
align-items: center
|
||||
width: 100%
|
||||
opacity: 0.25
|
||||
margin: 0 1px
|
||||
text-overflow: ellipsis
|
||||
overflow: hidden
|
||||
font-size: 0.8em
|
||||
cursor: pointer
|
||||
background: $primary
|
||||
color: white
|
||||
border: 2px solid black
|
||||
.selected
|
||||
opacity: 1 !important
|
||||
</style>
|
||||
241
src/components/scheduling/boat/CalendarHeaderComponent.vue
Normal file
241
src/components/scheduling/boat/CalendarHeaderComponent.vue
Normal file
@@ -0,0 +1,241 @@
|
||||
<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%">
|
||||
{{ monthFormatter(day, true) }}
|
||||
</div>
|
||||
<div style="width: 100%; font-size: 16px; font-weight: 700">
|
||||
{{ dayFormatter(day, false) }}
|
||||
</div>
|
||||
<div style="width: 100%; font-size: 10px">
|
||||
{{ 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([0, 1, 2, 3, 4, 5, 6]),
|
||||
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 = { timeZone: 'UTC', month: 'long' };
|
||||
const shortOptions = { timeZone: 'UTC', month: 'short' };
|
||||
|
||||
return createNativeLocaleFormatter(locale.value, (_tms, short) =>
|
||||
short ? shortOptions : longOptions
|
||||
);
|
||||
}
|
||||
|
||||
function weekdayFormatterFunc() {
|
||||
const longOptions = { timeZone: 'UTC', weekday: 'long' };
|
||||
const shortOptions = { timeZone: 'UTC', weekday: 'short' };
|
||||
|
||||
return createNativeLocaleFormatter(locale.value, (_tms, short) =>
|
||||
short ? shortOptions : longOptions
|
||||
);
|
||||
}
|
||||
|
||||
function dayFormatterFunc() {
|
||||
const longOptions = { timeZone: 'UTC', day: '2-digit' };
|
||||
const shortOptions = { 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: $primary
|
||||
display: flex
|
||||
flex-direction: row
|
||||
flex: 1 0 100%
|
||||
justify-content: space-between
|
||||
align-items: center
|
||||
overflow: hidden
|
||||
border-radius: 3px
|
||||
user-select: none
|
||||
|
||||
.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: $primary
|
||||
color: white
|
||||
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: white
|
||||
background: $primary
|
||||
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: #3f51b5 !important
|
||||
background: white !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';
|
||||
|
||||
const props = 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 { Boat, 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 = ref<Boat[]>(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>
|
||||
17
src/components/task/TaskListComponent.vue
Normal file
17
src/components/task/TaskListComponent.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<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 { defineProps } from 'vue';
|
||||
import type { Task } from 'src/stores/task';
|
||||
import TaskCardComponent from './TaskCardComponent.vue';
|
||||
|
||||
const props = defineProps<{ tasks: Task[] }>();
|
||||
</script>
|
||||
362
src/components/task/TaskTableComponent.vue
Normal file
362
src/components/task/TaskTableComponent.vue
Normal file
@@ -0,0 +1,362 @@
|
||||
<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) => 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 { useBoatStore } from 'src/stores/boat';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const router = useRouter();
|
||||
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' },
|
||||
];
|
||||
|
||||
const props = defineProps<{ tasks: Task[] }>();
|
||||
const taskStore = useTaskStore();
|
||||
const $q = useQuasar();
|
||||
|
||||
const searchFilter = ref({
|
||||
title: '',
|
||||
skillTags: <SkillTag[]>[],
|
||||
taskTags: <TaskTag[]>[],
|
||||
});
|
||||
|
||||
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: any, cols: any, cellValueFn: any) => {
|
||||
let result = rows;
|
||||
result = rows.filter((row) =>
|
||||
terms.title
|
||||
? row.title.toLowerCase().includes(terms.title.toLowerCase())
|
||||
: true
|
||||
);
|
||||
result = result.filter((row) =>
|
||||
terms.skillTags && terms.skillTags.length > 0
|
||||
? row.required_skills.some((req_skill) =>
|
||||
terms.skillTags.map((t) => t.$id).includes(req_skill)
|
||||
)
|
||||
: true
|
||||
);
|
||||
result = result.filter((row) =>
|
||||
terms.taskTags && terms.taskTags.length > 0
|
||||
? row.tags.some((tag) => terms.taskTags.map((t) => t.$id).includes(tag))
|
||||
: true
|
||||
);
|
||||
return result;
|
||||
}
|
||||
);
|
||||
|
||||
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>
|
||||
@@ -17,7 +17,7 @@
|
||||
<q-avatar icon="numbers" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
12345
|
||||
123456
|
||||
<q-item-label caption>Member ID</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
@@ -25,13 +25,13 @@
|
||||
<q-item>
|
||||
<q-item-section>
|
||||
<q-item-label overline>Certifications</q-item-label>
|
||||
<q-chip square icon="verified" color="primary" text-color="white"
|
||||
<q-chip square icon="verified" color="green" text-color="white"
|
||||
>J/27</q-chip
|
||||
>
|
||||
<q-chip square icon="verified" color="green" text-color="white"
|
||||
<q-chip square icon="verified" color="blue" text-color="white"
|
||||
>Capri25</q-chip
|
||||
>
|
||||
<q-chip square icon="verified" color="grey-8" text-color="white"
|
||||
<q-chip square icon="verified" color="red" text-color="white"
|
||||
>Night</q-chip
|
||||
>
|
||||
</q-item-section>
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
<template>
|
||||
<q-page padding>
|
||||
<q-page>
|
||||
<q-list>
|
||||
<q-form @submit="onSubmit" @reset="onReset" class="q-gutter-md">
|
||||
<q-form @submit="onSubmit" @reset="onReset" class="q-gutter-sm">
|
||||
<q-input
|
||||
bottom-slots
|
||||
v-model="bookingForm.name"
|
||||
@@ -21,14 +21,8 @@
|
||||
:caption="bookingSummary"
|
||||
>
|
||||
<q-separator />
|
||||
<resource-schedule-viewer-component
|
||||
@on-click-time="onClickTime"
|
||||
@on-update-duration="
|
||||
(value) => {
|
||||
bookingForm.duration = value;
|
||||
}
|
||||
"
|
||||
/>
|
||||
<boat-selection />
|
||||
|
||||
<q-banner
|
||||
rounded
|
||||
class="bg-warning text-grey-10"
|
||||
@@ -76,9 +70,9 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, computed, watch } from 'vue';
|
||||
import { useAuthStore } from 'src/stores/auth';
|
||||
import { Boat, useBoatStore } from 'src/stores/boat';
|
||||
import { Boat } from 'src/stores/boat';
|
||||
import { Dialog, date } from 'quasar';
|
||||
import ResourceScheduleViewerComponent from 'src/components/ResourceScheduleViewerComponent.vue';
|
||||
import BoatSelection from 'src/components/scheduling/BoatSelection.vue';
|
||||
import { makeDateTime } from '@quasar/quasar-ui-qcalendar';
|
||||
import { useScheduleStore, Reservation } from 'src/stores/schedule';
|
||||
|
||||
@@ -113,7 +107,7 @@ watch(bookingForm, (b, a) => {
|
||||
status: 'tentative',
|
||||
};
|
||||
//TODO: Turn this into a validator.
|
||||
scheduleStore.isOverlapped(newRes)
|
||||
scheduleStore.isReservationOverlapped(newRes)
|
||||
? Dialog.create({ message: 'This booking overlaps another!' })
|
||||
: scheduleStore.addOrCreateReservation(newRes);
|
||||
});
|
||||
|
||||
@@ -1,12 +1,145 @@
|
||||
<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, useScheduleStore } from 'src/stores/schedule';
|
||||
import { ref } from 'vue';
|
||||
const scheduleStore = useScheduleStore();
|
||||
scheduleStore.loadSampleData();
|
||||
import {
|
||||
TimestampOrNull,
|
||||
makeDateTime,
|
||||
makeDate,
|
||||
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());
|
||||
|
||||
// Use ref to get a reference to the QCalendarDay component
|
||||
const calendarRef = ref(QCalendarDay);
|
||||
|
||||
// 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 onToday() {
|
||||
calendarRef.value.moveToToday();
|
||||
}
|
||||
function onPrev() {
|
||||
calendarRef.value.prev();
|
||||
}
|
||||
function onNext() {
|
||||
calendarRef.value.next();
|
||||
}
|
||||
function onMoved(data) {
|
||||
console.log('onMoved', data);
|
||||
}
|
||||
function onChange(data) {
|
||||
console.log('onChange', data);
|
||||
}
|
||||
function onClickDate(data) {
|
||||
console.log('onClickDate', data);
|
||||
}
|
||||
function onClickTime(data) {
|
||||
console.log('onClickTime', data);
|
||||
}
|
||||
function onClickInterval(data) {
|
||||
console.log('onClickInterval', data);
|
||||
}
|
||||
function onClickHeadDay(data) {
|
||||
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>
|
||||
|
||||
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>
|
||||
@@ -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,83 @@ 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: '/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'),
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -112,7 +111,7 @@ const routes: RouteRecordRaw[] = [
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
component: LoginPageVue,
|
||||
component: () => import('pages/LoginPage.vue'),
|
||||
name: 'login',
|
||||
meta: {
|
||||
publicRoute: true,
|
||||
|
||||
@@ -3,8 +3,9 @@ import { defineStore } from 'pinia';
|
||||
// const boatSource = null;
|
||||
|
||||
export interface Boat {
|
||||
id: number;
|
||||
$id: string;
|
||||
name: string;
|
||||
displayName?: string;
|
||||
class?: string;
|
||||
year?: number;
|
||||
imgsrc?: string;
|
||||
@@ -25,8 +26,9 @@ export interface Boat {
|
||||
|
||||
const getSampleData = () => [
|
||||
{
|
||||
id: 1,
|
||||
$id: '1',
|
||||
name: 'ProjectX',
|
||||
displayName: 'PX',
|
||||
class: 'J/27',
|
||||
year: 1981,
|
||||
imgsrc: '/tmpimg/j27.png',
|
||||
@@ -50,16 +52,26 @@ and rough engine performance.`,
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
$id: '2',
|
||||
name: 'Take5',
|
||||
displayName: 'T5',
|
||||
class: 'J/27',
|
||||
year: 1985,
|
||||
imgsrc: '/tmpimg/j27.png',
|
||||
iconsrc: '/tmpimg/take5_avatar32.png',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
$id: '3',
|
||||
name: 'WeeBeestie',
|
||||
displayName: 'WB',
|
||||
class: 'Capri 25',
|
||||
year: 1989,
|
||||
imgsrc: '/tmpimg/capri25.png',
|
||||
},
|
||||
{
|
||||
$id: '3',
|
||||
name: 'Just My Imagination',
|
||||
displayName: 'JMI',
|
||||
class: 'Capri 25',
|
||||
year: 1989,
|
||||
imgsrc: '/tmpimg/capri25.png',
|
||||
|
||||
@@ -3,65 +3,107 @@ import { ref } from 'vue';
|
||||
import { Boat, useBoatStore } from './boat';
|
||||
import { date } from 'quasar';
|
||||
import { DateOptions } from 'quasar';
|
||||
import {
|
||||
Timestamp,
|
||||
parseDate,
|
||||
updateRelative,
|
||||
today,
|
||||
parsed,
|
||||
} from '@quasar/quasar-ui-qcalendar';
|
||||
|
||||
export interface Reservation {
|
||||
export type StatusTypes = 'tentative' | 'confirmed' | 'pending' | undefined;
|
||||
export type Reservation = {
|
||||
id: number;
|
||||
user: string;
|
||||
start: Date;
|
||||
end: Date;
|
||||
resource: Boat;
|
||||
reservationDate: Date;
|
||||
status?: string;
|
||||
}
|
||||
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? */
|
||||
|
||||
function getSampleData(): Reservation[] {
|
||||
export type Timeblock = {
|
||||
id: number;
|
||||
start: Timestamp;
|
||||
end: Timestamp;
|
||||
};
|
||||
|
||||
const sampleBlocks = [
|
||||
{
|
||||
id: 1,
|
||||
start: { time: '09:00', hour: 9, minute: 0, hasTime: true },
|
||||
end: { time: '11:30', hour: 12, minute: 0, hasTime: true },
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
start: { time: '12:00', hour: 12, minute: 0, hasTime: true },
|
||||
end: { time: '15:00', hour: 15, minute: 0, hasTime: true },
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
start: { time: '15:00', hour: 15, minute: 0, hasTime: true },
|
||||
end: { time: '18:00', hour: 18, minute: 0, hasTime: true },
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
start: { time: '18:00', hour: 18, minute: 0, hasTime: true },
|
||||
end: { time: '21:00', hour: 21, minute: 0, hasTime: true },
|
||||
},
|
||||
] as Timeblock[];
|
||||
|
||||
function getSampleReservations(): Reservation[] {
|
||||
const sampleData = [
|
||||
{
|
||||
id: 1,
|
||||
user: 'John Smith',
|
||||
start: '12:00',
|
||||
end: '14:00',
|
||||
boat: 1,
|
||||
end: '15:00',
|
||||
boat: '1',
|
||||
status: 'confirmed',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
user: 'Bob Barker',
|
||||
start: '18:00',
|
||||
end: '20:00',
|
||||
boat: 1,
|
||||
end: '21:00',
|
||||
boat: '1',
|
||||
status: 'confirmed',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
user: 'Peter Parker',
|
||||
start: '8:00',
|
||||
end: '10:00',
|
||||
boat: 2,
|
||||
start: '9:00',
|
||||
end: '12:00',
|
||||
boat: '2',
|
||||
status: 'tentative',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
user: 'Vince McMahon',
|
||||
start: '13:00',
|
||||
end: '17:00',
|
||||
boat: 2,
|
||||
start: '15:00',
|
||||
end: '18:00',
|
||||
boat: '2',
|
||||
status: 'pending',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
user: 'Heather Graham',
|
||||
start: '06:00',
|
||||
end: '09:00',
|
||||
boat: 3,
|
||||
start: '09:00',
|
||||
end: '12:00',
|
||||
boat: '3',
|
||||
status: 'confirmed',
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
user: 'Lawrence Fishburne',
|
||||
start: '18:00',
|
||||
end: '20:00',
|
||||
boat: 3,
|
||||
end: '21:00',
|
||||
boat: '3',
|
||||
},
|
||||
];
|
||||
const boatStore = useBoatStore();
|
||||
@@ -70,11 +112,16 @@ function getSampleData(): Reservation[] {
|
||||
return x.split(':');
|
||||
};
|
||||
const makeOpts = (x: string[]): DateOptions => {
|
||||
return { hour: parseInt(x[0]), minute: parseInt(x[1]) };
|
||||
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);
|
||||
const boat = <Boat>boatStore.boats.find((b) => b.$id == entry.boat);
|
||||
return {
|
||||
id: entry.id,
|
||||
user: entry.user,
|
||||
@@ -82,38 +129,67 @@ function getSampleData(): Reservation[] {
|
||||
end: date.adjustDate(now, makeOpts(splitTime(entry.end))),
|
||||
resource: boat,
|
||||
reservationDate: now,
|
||||
status: entry.status,
|
||||
status: entry.status as StatusTypes,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export const useScheduleStore = defineStore('schedule', () => {
|
||||
const reservations = ref<Reservation[]>(getSampleData());
|
||||
// TODO: Implement functions to dynamically pull this data.
|
||||
const reservations = ref<Reservation[]>(getSampleReservations());
|
||||
const timeblocks = sampleBlocks;
|
||||
|
||||
const getTimeblocksForDate = (date: Timestamp): Timeblock[] => {
|
||||
return timeblocks.map((t) => {
|
||||
return {
|
||||
// Update all the start/end times with the passed in date
|
||||
...t,
|
||||
start: { ...date, ...t.start },
|
||||
end: { ...date, ...t.end },
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const getBoatReservations = (
|
||||
boat: number | string,
|
||||
curDate: Date
|
||||
searchDate: Timestamp,
|
||||
boat?: string
|
||||
): Reservation[] => {
|
||||
return reservations.value.filter((x) => {
|
||||
console.log(searchDate);
|
||||
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)
|
||||
((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 isOverlapped = (res: Reservation) => {
|
||||
const lapped = reservations.value.filter(
|
||||
const getConflictingReservations = (
|
||||
resource: Boat,
|
||||
start: Date,
|
||||
end: Date
|
||||
): Reservation[] => {
|
||||
const overlapped = 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))
|
||||
entry.resource.$id == resource.$id &&
|
||||
entry.start < end &&
|
||||
entry.end > start
|
||||
);
|
||||
return lapped.length > 0;
|
||||
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 = () => {
|
||||
@@ -133,8 +209,11 @@ export const useScheduleStore = defineStore('schedule', () => {
|
||||
return {
|
||||
reservations,
|
||||
getBoatReservations,
|
||||
getConflictingReservations,
|
||||
getTimeblocksForDate,
|
||||
getNewId,
|
||||
addOrCreateReservation,
|
||||
isOverlapped,
|
||||
isReservationOverlapped,
|
||||
isResourceTimeOverlapped,
|
||||
};
|
||||
});
|
||||
|
||||
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.collectionIdTask
|
||||
);
|
||||
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.collectionIdTaskTags
|
||||
);
|
||||
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.collectionIdSkillTags
|
||||
);
|
||||
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 {
|
||||
const response = await databases.deleteDocument(
|
||||
AppwriteIds.databaseId,
|
||||
AppwriteIds.collectionIdTask,
|
||||
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.collectionIdTask,
|
||||
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.collectionIdTask,
|
||||
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": "."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user