1
0

Init Commit

This commit is contained in:
Kyle Austad 2025-02-26 15:17:36 -06:00
parent a738d3687b
commit c38b2e389a
82 changed files with 15371 additions and 0 deletions

10
.dockerignore Normal file
View File

@ -0,0 +1,10 @@
node_modules
dist
android
.git
.gitignore
Dockerfile
.dockerignore
README.md
.env
.vscode

9
.editorconfig Normal file
View File

@ -0,0 +1,9 @@
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue}]
charset = utf-8
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
end_of_line = lf
max_line_length = 100

25
.gitignore vendored Normal file
View File

@ -0,0 +1,25 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
dev-dist
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

7
.prettierrc.json Normal file
View File

@ -0,0 +1,7 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"singleQuote": true,
"printWidth": 100
}

3
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

5
README.md Normal file
View File

@ -0,0 +1,5 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).

26
dockerfile Normal file
View File

@ -0,0 +1,26 @@
# Use Node 20 as the base image
FROM node:20-alpine AS build
# Set the working directory
WORKDIR /app
# Copy package.json and package-lock.json to leverage caching
COPY package*.json ./
# Install dependencies
RUN npm install
# Copy the rest of the application files
COPY . .
# Ensure Ionic CLI can be found by npm scripts
ENV PATH="./node_modules/.bin:$PATH"
# Build the Ionic Vue app
RUN npm run build
# Expose the Vite preview server port
EXPOSE 4173
# Start the app using Vite's preview server
CMD ["npm", "run", "preview"]

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0l, user-scalable=no" />
<title>justCanvas</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

9317
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

48
package.json Normal file
View File

@ -0,0 +1,48 @@
{
"name": "new-canvas",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --host",
"build": "vue-tsc -b && vite build",
"preview": "vite preview --host"
},
"dependencies": {
"@primevue/forms": "^4.2.5",
"@primevue/themes": "^4.2.5",
"@vueuse/core": "^12.7.0",
"@vueuse/integrations": "^12.7.0",
"@vueuse/motion": "^2.2.6",
"async-validator": "^4.2.5",
"axios": "^1.7.9",
"color": "^5.0.0",
"fs": "^0.0.1-security",
"js-cookie": "^3.0.5",
"ol": "^10.4.0",
"ol-contextmenu": "^5.5.0",
"ol-ext": "^4.0.27",
"pinia": "^3.0.1",
"primeicons": "^7.0.0",
"primevue": "^4.2.5",
"vue-router": "^4.5.0",
"vue3-openlayers": "^11.3.2"
},
"devDependencies": {
"@types/js-cookie": "^3.0.6",
"@vite-pwa/assets-generator": "^0.2.6",
"@vitejs/plugin-vue": "^5.2.1",
"@vue/compiler-sfc": "^3.5.13",
"@vue/tsconfig": "^0.7.0",
"typescript": "~5.7.2",
"vite": "^6.0.11",
"vite-plugin-pwa": "^0.21.1",
"vue": "^3.5.13",
"vue-tsc": "^2.2.0",
"workbox-window": "^7.3.0"
},
"overrides": {
"sharp": "0.32.6",
"sharp-ico": "0.1.5"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

1
public/favicon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 26 KiB

12
pwa-assets.config.ts Normal file
View File

@ -0,0 +1,12 @@
import {
defineConfig,
minimal2023Preset as preset,
} from '@vite-pwa/assets-generator/config'
export default defineConfig({
headLinkOptions: {
preset: '2023',
},
preset,
images: ['public/favicon.svg'],
})

7
src/App.vue Normal file
View File

@ -0,0 +1,7 @@
<script setup lang="ts"></script>
<template>
<RouterView />
</template>
<style scoped></style>

28
src/assets/icons.ts Normal file
View File

@ -0,0 +1,28 @@
export const pinBlank =
"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='250' height='488.997' viewBox='0 0 250 488.997'><g transform='translate(-127.601 -12.399)'><circle style='fill:none;fill-rule:evenodd;stroke:currentColor;stroke-width:22.6748;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-dashoffset:4' cx='252.601' cy='137.399' r='113.663'/><path style='fill:currentColor;fill-opacity:1;fill-rule:evenodd;stroke:currentColor;stroke-width:25;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-dashoffset:4' d='m151.64 219.828 101.976 269.07 101.976-269.07-101.976 52.197Z'/><circle style='fill:none;fill-rule:evenodd;stroke:currentColor;stroke-width:51.5434;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-dashoffset:4' cx='253.526' cy='137.11' r='79.278'/></g></svg>";
export const pinRed =
"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='250' height='488.997' viewBox='0 0 250 488.997'><g transform='translate(-127.601 -12.399)'><circle style='fill:none;fill-rule:evenodd;stroke:orangered;stroke-width:22.6748;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-dashoffset:4' cx='252.601' cy='137.399' r='113.663'/><path style='fill:orangered;fill-opacity:1;fill-rule:evenodd;stroke:orangered;stroke-width:25;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-dashoffset:4' d='m151.64 219.828 101.976 269.07 101.976-269.07-101.976 52.197Z'/><circle style='fill:none;fill-rule:evenodd;stroke:orangered;stroke-width:51.5434;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-dashoffset:4' cx='253.526' cy='137.11' r='79.278'/></g></svg>";
export const pinBlue =
"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='250' height='488.997' viewBox='0 0 250 488.997'><g transform='translate(-127.601 -12.399)'><circle style='fill:none;fill-rule:evenodd;stroke:blue;stroke-width:22.6748;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-dashoffset:4' cx='252.601' cy='137.399' r='113.663'/><path style='fill:blue;fill-opacity:1;fill-rule:evenodd;stroke:blue;stroke-width:25;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-dashoffset:4' d='m151.64 219.828 101.976 269.07 101.976-269.07-101.976 52.197Z'/><circle style='fill:none;fill-rule:evenodd;stroke:blue;stroke-width:51.5434;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-dashoffset:4' cx='253.526' cy='137.11' r='79.278'/></g></svg>";
export const pinGray =
"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='250' height='488.997' viewBox='0 0 250 488.997'><g transform='translate(-127.601 -12.399)'><circle style='fill:none;fill-rule:evenodd;stroke:gray;stroke-width:22.6748;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-dashoffset:4' cx='252.601' cy='137.399' r='113.663'/><path style='fill:gray;fill-opacity:1;fill-rule:evenodd;stroke:gray;stroke-width:25;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-dashoffset:4' d='m151.64 219.828 101.976 269.07 101.976-269.07-101.976 52.197Z'/><circle style='fill:none;fill-rule:evenodd;stroke:gray;stroke-width:51.5434;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-dashoffset:4' cx='253.526' cy='137.11' r='79.278'/></g></svg>";
export const pinGreen =
"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='250' height='488.997' viewBox='0 0 250 488.997'><g transform='translate(-127.601 -12.399)'><circle style='fill:none;fill-rule:evenodd;stroke:green;stroke-width:22.6748;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-dashoffset:4' cx='252.601' cy='137.399' r='113.663'/><path style='fill:green;fill-opacity:1;fill-rule:evenodd;stroke:green;stroke-width:25;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-dashoffset:4' d='m151.64 219.828 101.976 269.07 101.976-269.07-101.976 52.197Z'/><circle style='fill:none;fill-rule:evenodd;stroke:green;stroke-width:51.5434;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-dashoffset:4' cx='253.526' cy='137.11' r='79.278'/></g></svg>";
export const pinBlack =
"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='250' height='488.997' viewBox='0 0 250 488.997'><g transform='translate(-127.601 -12.399)'><circle style='fill:none;fill-rule:evenodd;stroke:black;stroke-width:22.6748;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-dashoffset:4' cx='252.601' cy='137.399' r='113.663'/><path style='fill:black;fill-opacity:1;fill-rule:evenodd;stroke:black;stroke-width:25;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-dashoffset:4' d='m151.64 219.828 101.976 269.07 101.976-269.07-101.976 52.197Z'/><circle style='fill:none;fill-rule:evenodd;stroke:black;stroke-width:51.5434;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-dashoffset:4' cx='253.526' cy='137.11' r='79.278'/></g></svg>";
export const pinWhite =
"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='250' height='488.997' viewBox='0 0 250 488.997'><g transform='translate(-127.601 -12.399)'><circle style='fill:none;fill-rule:evenodd;stroke:white;stroke-width:22.6748;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-dashoffset:4' cx='252.601' cy='137.399' r='113.663'/><path style='fill:white;fill-opacity:1;fill-rule:evenodd;stroke:white;stroke-width:25;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-dashoffset:4' d='m151.64 219.828 101.976 269.07 101.976-269.07-101.976 52.197Z'/><circle style='fill:none;fill-rule:evenodd;stroke:white;stroke-width:51.5434;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-dashoffset:4' cx='253.526' cy='137.11' r='79.278'/></g></svg>";
export const pinYellow =
"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='250' height='488.997' viewBox='0 0 250 488.997'><g transform='translate(-127.601 -12.399)'><circle style='fill:none;fill-rule:evenodd;stroke:yellow;stroke-width:22.6748;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-dashoffset:4' cx='252.601' cy='137.399' r='113.663'/><path style='fill:yellow;fill-opacity:1;fill-rule:evenodd;stroke:yellow;stroke-width:25;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-dashoffset:4' d='m151.64 219.828 101.976 269.07 101.976-269.07-101.976 52.197Z'/><circle style='fill:none;fill-rule:evenodd;stroke:yellow;stroke-width:51.5434;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-dashoffset:4' cx='253.526' cy='137.11' r='79.278'/></g></svg>";
export const pinLogo =
"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='250' height='488.997' viewBox='0 0 250 488.997'><g transform='translate(-127.601 -12.399)'><circle style='fill:none;fill-rule:evenodd;stroke:turquoise;stroke-width:22.6748;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-dashoffset:4' cx='252.601' cy='137.399' r='113.663'/><path style='fill:white;fill-opacity:1;fill-rule:evenodd;stroke:turquoise;stroke-width:25;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-dashoffset:4' d='m151.64 219.828 101.976 269.07 101.976-269.07-101.976 52.197Z'/><circle style='fill:none;fill-rule:evenodd;stroke:white;stroke-width:51.5434;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-dashoffset:4' cx='253.526' cy='137.11' r='79.278'/></g></svg>";
export const pinFilled =
"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='250' height='488.997' viewBox='0 0 250 488.997'><g transform='translate(-127.601 -12.399)'><circle style='fill:currentColor;fill-rule:evenodd;stroke:currentColor;stroke-width:22.6748;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-dashoffset:4' cx='252.601' cy='137.399' r='113.663'/><path style='fill:currentColor;fill-opacity:1;fill-rule:evenodd;stroke:currentColor;stroke-width:25;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-dashoffset:4' d='m151.64 219.828 101.976 269.07 101.976-269.07-101.976 52.197Z'/><circle style='fill:currentColor;fill-rule:evenodd;stroke:currentColor;stroke-width:51.5434;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-dashoffset:4' cx='253.526' cy='137.11' r='79.278'/></g></svg>";
export const locationIcon =
"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0NTAuNjUiIGhlaWdodD0iNDUwLjY1IiB2aWV3Qm94PSIwIDAgNDUwLjY1IDQ1MC42NSI+PGRlZnM+PHJhZGlhbEdyYWRpZW50IGlkPSJiIiBjeD0iMjI1LjMyNSIgY3k9IjIyNS4zMjUiIHI9IjIyNS4zMjUiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIj48c3RvcCBvZmZzZXQ9IjAiIHN0eWxlPSJzdG9wLWNvbG9yOiM1ZTkzZmY7c3RvcC1vcGFjaXR5OjEiLz48c3RvcCBvZmZzZXQ9IjEiIHN0eWxlPSJzdG9wLWNvbG9yOiMwMDU0ZmU7c3RvcC1vcGFjaXR5OjEiLz48L3JhZGlhbEdyYWRpZW50PjwvZGVmcz48ZyB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMTI3LjYwMSAtMTIuMzk5KSI+PGNpcmNsZSBzdHlsZT0iZmlsbDp1cmwoI2IpO2ZpbGwtb3BhY2l0eToxO3N0cm9rZTojZmZmO3N0cm9rZS13aWR0aDo1MC42NTtzdHJva2UtbGluZWNhcDpyb3VuZDtzdHJva2UtbGluZWpvaW46cm91bmQ7c3Ryb2tlLWRhc2hvZmZzZXQ6NDtzdHJva2Utb3BhY2l0eToxIiBjeD0iMzUzLjkyNiIgY3k9IjIzNy43MjQiIHI9IjIwMCIvPjxjaXJjbGUgc3R5bGU9ImZpbGw6I2ZmZjtmaWxsLW9wYWNpdHk6MTtzdHJva2U6I2ZmZjtzdHJva2Utd2lkdGg6MjguODU7c3Ryb2tlLWxpbmVjYXA6cm91bmQ7c3Ryb2tlLWxpbmVqb2luOnJvdW5kO3N0cm9rZS1kYXNob2Zmc2V0OjQ7c3Ryb2tlLW9wYWNpdHk6MSIgY3g9IjM1My45MjYiIGN5PSIyMzcuNzI0IiByPSI1MCIvPjwvZz48L3N2Zz4=";
export const locationIconBlack = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0NTAuNjUiIGhlaWdodD0iNDUwLjY1IiB2aWV3Qm94PSIwIDAgNDUwLjY1IDQ1MC42NSI+PGRlZnM+PHJhZGlhbEdyYWRpZW50IGlkPSJiIiBjeD0iMjI1LjMyNSIgY3k9IjIyNS4zMjUiIHI9IjIyNS4zMjUiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIj48c3RvcCBvZmZzZXQ9IjAiIHN0eWxlPSJzdG9wLWNvbG9yOiM1ZTkzZmY7c3RvcC1vcGFjaXR5OjEiLz48c3RvcCBvZmZzZXQ9IjEiIHN0eWxlPSJzdG9wLWNvbG9yOiMwMDU0ZmU7c3RvcC1vcGFjaXR5OjEiLz48L3JhZGlhbEdyYWRpZW50PjwvZGVmcz48ZyB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMTI3LjYwMSAtMTIuMzk5KSI+PGNpcmNsZSBzdHlsZT0iZmlsbDp1cmwoI2IpO2ZpbGwtb3BhY2l0eToxO3N0cm9rZTojMDAwO3N0cm9rZS13aWR0aDo1MC42NTtzdHJva2UtbGluZWNhcDpyb3VuZDtzdHJva2UtbGluZWpvaW46cm91bmQ7c3Ryb2tlLWRhc2hvZmZzZXQ6NDtzdHJva2Utb3BhY2l0eToxIiBjeD0iMzUzLjkyNiIgY3k9IjIzNy43MjQiIHI9IjIwMCIvPjxjaXJjbGUgc3R5bGU9ImZpbGw6I2ZmZjtmaWxsLW9wYWNpdHk6MTtzdHJva2U6I2ZmZjtzdHJva2Utd2lkdGg6MjguODU7c3Ryb2tlLWxpbmVjYXA6cm91bmQ7c3Ryb2tlLWxpbmVqb2luOnJvdW5kO3N0cm9rZS1kYXNob2Zmc2V0OjQ7c3Ryb2tlLW9wYWNpdHk6MSIgY3g9IjM1My45MjYiIGN5PSIyMzcuNzI0IiByPSI1MCIvPjwvZz48L3N2Zz4="

1
src/assets/vue.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View File

@ -0,0 +1,53 @@
<script setup lang="ts">
import { useOfficeStore } from '../stores/officeStore'
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/authStore'
const router = useRouter()
const officeStore = useOfficeStore()
const authStore = useAuthStore()
const activeOffice = ref<string | undefined>(officeStore.activeOffice?.name)
const handleClick = () => {
if (authStore.user.offices.length > 1) {
router.push('/office-select')
return
} else {
return
}
}
</script>
<template>
<div
class="office-label"
style="position: absolute; top: calc(5% - 0.5rem); left: calc(50% - 2.5rem); cursor: pointer"
v-motion-slide-top
@click="handleClick"
>
<p class="office-label-text">{{ activeOffice }}</p>
</div>
</template>
<style lang="css" scoped>
.office-label {
z-index: 850;
background-color: #1f1f1fb9;
box-shadow: 5px 5px 5px #0000003a;
border-radius: 20px;
height: auto;
width: auto;
text-align: center;
text-wrap: nowrap;
}
.office-label-text {
color: white;
font-size: small;
margin-left: 10px;
margin-right: 10px;
margin-top: 2px;
margin-bottom: 2px;
}
</style>

View File

@ -0,0 +1,31 @@
<script setup lang="ts">
import { Button } from 'primevue'
import { useRouter } from 'vue-router'
const props = defineProps<{
mode: 'teams' | 'users'
}>()
const router = useRouter()
const handleBackClick = () => {
router.push(props.mode === 'teams' ? '/teams/add-team' : '/users/add-user')
}
</script>
<template>
<Button
@click="handleBackClick"
v-motion-slide-right
severity="contrast"
label="Add"
icon="pi pi-plus-circle"
></Button>
</template>
<style lang="css" scoped>
Button {
position: absolute;
right: 10%;
top: 3%;
}
</style>

View File

@ -0,0 +1,122 @@
<script setup lang="ts">
import { Form, type FormResolverOptions, type FormSubmitEvent } from '@primevue/forms'
import { InputText, Message, Button, Toast, ConfirmDialog } from 'primevue'
import { reactive } from 'vue'
import { useOfficeStore } from '../stores/officeStore'
import type { Office } from '../types/office'
import { useRouter } from 'vue-router'
import { useToast } from 'primevue/usetoast'
const toast = useToast()
const officeStore = useOfficeStore()
const router = useRouter()
interface FormValues {
name: string
city: string
state: string
}
interface FormErrors {
name?: { message: string }[]
city?: { message: string }[]
state?: { message: string }[]
}
const initialValues = reactive<Office>({
name: '',
city: '',
state: '',
})
const resolver = ({ values }: FormResolverOptions) => {
const errors: FormErrors = {}
// Name Validation
if (!values.name) {
errors.name = [{ message: 'Name is required' }]
} else if (values.name.length < 5) {
errors.name = [{ message: 'Name must be at least 5 characters long!' }]
} else if (values.name.length > 25) {
errors.name = [{ message: 'Name must be less than 25 characters long!' }]
} else if (officeStore.allOffices.some((office) => office.name === values.name)) {
errors.name = [{ message: 'Another Team Exists With That Name!' }]
}
// City Validation
if (!values.city) {
errors.city = [{ message: 'City is required' }]
} else if (values.city.length < 3) {
errors.city = [{ message: 'City must be at least 3 characters long!' }]
} else if (values.city.length > 25) {
errors.city = [{ message: 'City must be less than 25 characters long!' }]
}
// State Validation
if (!values.state) {
errors.state = [{ message: 'State is required' }]
} else if (values.state.length < 2) {
errors.state = [{ message: 'State must be at least 2 characters long!' }]
} else if (values.state.length > 25) {
errors.state = [{ message: 'State must be less than 25 characters long!' }]
}
return {
values,
errors,
}
}
const onFormSubmit = async (form: FormSubmitEvent) => {
if (form.valid) {
console.log('Form VALID!', form.values)
await officeStore.saveNewOffice(form.values as Office)
toast.add({
severity: 'contrast',
summary: 'Added New Team',
detail: 'New Team Created and Saved!',
life: 2200,
})
setTimeout(() => {
router.push('/teams')
}, 3000)
}
}
</script>
<template>
<ConfirmDialog />
<Toast style="max-width: 300px" />
<Form v-slot="$form" :initialValues :resolver @submit="onFormSubmit">
<div class="form-group">
<h3>Team Name</h3>
<InputText name="name" type="text" placeholder="Team Name" fluid />
<Message v-if="$form.name?.invalid" severity="error" variant="simple">{{
$form.name?.error?.message
}}</Message>
</div>
<div class="form-group">
<h3>City</h3>
<InputText name="city" type="text" placeholder="City" fluid />
<Message v-if="$form.city?.invalid" severity="error" variant="simple">{{
$form.city?.error?.message
}}</Message>
</div>
<div class="form-group">
<h3>State</h3>
<InputText name="state" type="text" placeholder="State" fluid />
<Message v-if="$form.state?.invalid" severity="error" variant="simple">{{
$form.state?.error?.message
}}</Message>
</div>
<div class="form-buttons">
<Button type="submit" severity="info" label="Create" icon="pi pi-plus" />
</div>
</Form>
</template>

View File

@ -0,0 +1,251 @@
<script setup lang="ts">
import { Form, type FormResolverOptions, type FormSubmitEvent } from '@primevue/forms'
import {
InputText,
Message,
Button,
Toast,
ConfirmDialog,
ScrollPanel,
Select,
MultiSelect,
} from 'primevue'
import { reactive, ref } from 'vue'
import { useAxios } from '@vueuse/integrations/useAxios'
import { useUsersStore } from '../stores/usersStore'
import { useAuthStore } from '../stores/authStore'
import type { User } from '../types/user'
import { useRouter } from 'vue-router'
import { useToast } from 'primevue/usetoast'
import type { Office } from '../types/office'
const toast = useToast()
const usersStore = useUsersStore()
const authStore = useAuthStore()
const router = useRouter()
interface Role {
name: string
value: number
}
const props = defineProps<{
possibleOffices: Office[]
possibleRoles: Role[]
possibleSupervisors: User[]
}>()
interface FormValues {
username: string
city: string
state: string
}
interface FormErrors {
username?: { message: string }[]
password?: { message: string }[]
firstName?: { message: string }[]
lastName?: { message: string }[]
role?: { message: string }[]
offices?: { message: string }[]
supervisor?: { message: string }[]
}
const initialValues = reactive<User>({
username: '',
password: '',
firstName: '',
lastName: '',
role: 0,
offices: [],
})
const selectedRole = ref(0)
const usernameAvailable = ref(true)
const isUsernameAvailable = async (username: string) => {
const { data, error } = await useAxios(
`https://${authStore.company}.justcanvas.app/api/v1/users/check-username/${username}`,
{
method: 'GET',
withCredentials: true,
}
)
if (error.value) {
console.log('error checking username', error.value)
throw error.value
}
return data.value.available
}
const getFullName = (option: User) => {
return `${option.firstName} ${option.lastName}`
}
const resolver = ({ values }: FormResolverOptions) => {
const errors: FormErrors = {}
// Username Validation
if (!values.username) {
errors.username = [{ message: 'Username is required' }]
} else if (values.username.includes(' ')) {
errors.username = [{ message: 'Username cannot contain spaces!' }]
} else if (values.username.length < 5) {
errors.username = [{ message: 'username must be at least 5 characters long!' }]
} else if (values.username.length > 25) {
errors.username = [{ message: 'username must be less than 25 characters long!' }]
}
// FirstName Validation
if (!values.firstName) {
errors.firstName = [{ message: 'First Name is required' }]
} else if (values.firstName.length > 25) {
errors.firstName = [{ message: 'First Name must be less than 25 characters long!' }]
}
// Passsword Validation
if (!values.password) {
errors.password = [{ message: 'Password is required' }]
} else if (values.password.length < 5) {
errors.password = [{ message: 'Password must be at least 5 characters long!' }]
}
// LastName Validation
if (!values.lastName) {
errors.lastName = [{ message: 'Last Name is required' }]
} else if (values.lastName.length > 25) {
errors.lastName = [{ message: 'Last Name must be less than 25 characters long!' }]
}
//Teams Validation
if (values.offices.length === 0) {
errors.offices = [{ message: 'At least one team is required!' }]
}
return {
values,
errors,
}
}
const onFormSubmit = async (form: FormSubmitEvent) => {
if (form.valid) {
console.log('Form VALID!', form.values)
const usernameIsAvailable = await isUsernameAvailable(form.values.username)
if (usernameIsAvailable === false) {
usernameAvailable.value = false
return
}
usernameAvailable.value = true
await usersStore.saveNewUser(form.values as User)
// await officeStore.saveNewOffice(form.values as Office)
toast.add({
severity: 'contrast',
summary: 'Added New User',
detail: 'New User Created and Saved!',
life: 2200,
})
setTimeout(() => {
router.push('/users')
}, 3000)
}
}
</script>
<template>
<ConfirmDialog />
<Toast style="max-width: 300px" />
<ScrollPanel style="width: 100%; height: 100%">
<Form v-slot="$form" :initialValues :resolver @submit="onFormSubmit">
<div class="form-group" v-motion-pop-visible>
<h3>Username</h3>
<InputText name="username" type="text" placeholder="Username" fluid />
<Message v-if="$form.username?.invalid" severity="error" variant="simple">{{
$form.username?.error?.message
}}</Message>
<Message v-if="!usernameAvailable" severity="error" variant="simple"
>That Username is Already Taken!</Message
>
</div>
<div class="form-group" v-motion-pop-visible>
<h3>Password</h3>
<InputText name="password" type="password" placeholder="Password" fluid />
<Message v-if="$form.password?.invalid" severity="error" variant="simple">{{
$form.password?.error?.message
}}</Message>
</div>
<div class="form-group" v-motion-pop-visible>
<h3>First Name</h3>
<InputText name="firstName" type="text" placeholder="First Name" fluid />
<Message v-if="$form.firstName?.invalid" severity="error" variant="simple">{{
$form.firstName?.error?.message
}}</Message>
</div>
<div class="form-group" v-motion-pop-visible>
<h3>Last Name</h3>
<InputText name="lastName" type="text" placeholder="Last Name" fluid />
<Message v-if="$form.lastName?.invalid" severity="error" variant="simple">{{
$form.lastName?.error?.message
}}</Message>
</div>
<div class="form-group" v-motion-pop-visible>
<h3>Role</h3>
<Select
name="role"
v-model="selectedRole"
:options="props.possibleRoles"
optionValue="value"
optionLabel="name"
fluid
></Select>
<Message v-if="$form.role?.invalid" severity="error" variant="simple">{{
$form.role?.error?.message
}}</Message>
</div>
<div class="form-group" v-motion-pop-visible>
<h3>Teams</h3>
<MultiSelect
placeholder="Select Assigned Teams"
name="offices"
filter
optionLabel="name"
:options="props.possibleOffices"
optionValue="_id"
style="display: flex"
></MultiSelect>
<Message v-if="$form.offices?.invalid" severity="error" variant="simple">{{
$form.offices?.error?.message
}}</Message>
</div>
<div class="form-group" v-motion-pop-visible>
<h3>Supervisor</h3>
<Select
:options="possibleSupervisors"
filter
name="supervisor"
placeholder="Select a Supervisor"
optionValue="_id"
:optionLabel="getFullName"
></Select>
<Message v-if="$form.supervisor?.invalid" severity="error" variant="simple">{{
$form.supervisor?.error?.message
}}</Message>
</div>
<div class="form-buttons" v-motion-pop-visible>
<Button type="submit" severity="info" label="Create" icon="pi pi-plus" />
</div>
</Form>
</ScrollPanel>
</template>

View File

@ -0,0 +1,15 @@
<script setup lang="ts">
import { ScrollPanel } from 'primevue'
import SingleOfficeListItem from './SingleOfficeListItem.vue'
import type { Office } from '../types/office'
const props = defineProps<{
offices: Office[]
}>()
</script>
<template>
<ScrollPanel style="width: 100%; height: 100%">
<SingleOfficeListItem v-for="office in props.offices" :key="office._id" :office="office" />
</ScrollPanel>
</template>

View File

@ -0,0 +1,14 @@
<script setup lang="ts">
import { ScrollPanel } from 'primevue'
import SingleUserListItem from './SingleUserListItem.vue'
import { useUsersStore } from '../stores/usersStore'
const usersStore = useUsersStore()
</script>
<template>
<ScrollPanel style="width: 100%; height: 100%">
<SingleUserListItem v-for="user in usersStore.filteredUsers" :key="user._id" :user="user" />
</ScrollPanel>
</template>

View File

@ -0,0 +1,47 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ToggleSwitch } from 'primevue'
const isEnabled = ref(false)
const emit = defineEmits<{
(event: 'toggled-modes', value: boolean): void
}>()
const handleToggle = () => {
emit('toggled-modes', isEnabled.value)
}
</script>
<template>
<div class="toggle-switch" v-motion-slide-bottom>
<p>Select Area</p>
<ToggleSwitch @click="handleToggle" v-model="isEnabled"></ToggleSwitch>
<p>Draw Area</p>
</div>
</template>
<style lang="css" scoped>
.toggle-switch {
position: absolute;
bottom: calc(10% - 30px);
left: calc(50% - 100px);
display: flex;
align-items: center;
justify-content: space-around;
background-color: #57ac7b;
width: 200px;
border-radius: 30px;
height: 40px;
padding-left: 10px;
padding-right: 10px;
box-shadow: 10px 10px 10px #242424a6;
z-index: 1000;
}
.toggle-switch p {
font-weight: light;
font-family: inherit;
font-size: small;
}
</style>

View File

@ -0,0 +1,27 @@
<script setup lang="ts">
import { Button } from 'primevue'
import { useRouter } from 'vue-router'
const router = useRouter()
const handleBackClick = () => {
router.back()
}
</script>
<template>
<Button
@click="handleBackClick"
v-motion-slide-left
severity="secondary"
label="Back"
icon="pi pi-arrow-circle-left"
></Button>
</template>
<style lang="css" scoped>
Button {
position: absolute;
left: 10%;
top: 3%;
}
</style>

View File

@ -0,0 +1,101 @@
<script setup lang="ts">
import { ref } from 'vue'
import SpeedDial from 'primevue/speeddial'
import { Button, useConfirm, useToast } from 'primevue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/authStore'
import Cookies from 'js-cookie'
const authStore = useAuthStore()
const router = useRouter()
const confirm = useConfirm()
const toast = useToast()
const clearAllCookies = () => {
Object.keys(Cookies.get()).forEach((cookie) => {
Cookies.remove(cookie)
})
}
const confirmLogout = () => {
confirm.require({
message: 'Are you sure you want to logout?',
header: 'Logout?',
icon: 'pi pi-sign-out',
rejectLabel: 'Cancel',
rejectProps: {
label: 'Cancel',
severity: 'secondary',
outlined: true,
},
rejectIcon: 'pi pi-times-circle',
acceptProps: {
label: 'Logout',
severity: 'delete',
},
acceptIcon: 'pi pi-check-circle',
accept: () => {
toast.add({
severity: 'secondary',
summary: 'Confirmed',
detail: 'You have successfully logged out!',
life: 2500,
})
clearAllCookies()
setTimeout(() => {
authStore.logout()
router.push('/login')
}, 2650)
},
reject: () => {},
})
}
const menuItems = ref([
{
label: 'Menu',
icon: 'pi pi-bars',
command: () => router.push('/menu'),
},
{
label: 'Map',
icon: 'pi pi-map',
command: () => router.push('/'),
},
{
label: 'Login/Logout',
icon: authStore.isAuthenticated ? 'pi pi-sign-out' : 'pi pi-sign-in',
command: () => {
authStore.isAuthenticated ? confirmLogout() : router.push('/login')
},
},
])
</script>
<template>
<SpeedDial
:model="menuItems"
direction="up"
style="position: absolute; bottom: calc(10% - 2rem); left: calc(10% - 1rem); z-index: 1000"
class="floating-button"
>
<template #button="{ toggleCallback }">
<Button
class="pi p-button-rounded p-button-animated shadow-button"
@click="toggleCallback"
style="padding: 10px"
v-motion-slide-left
icon="pi pi-ellipsis-h"
>
<!-- <i class="pi pi-ellipsis-h"></i> -->
</Button>
</template>
</SpeedDial>
</template>
<style>
.floating-button {
z-index: 900;
}
</style>

View File

@ -0,0 +1,158 @@
<script setup lang="ts">
import type { Area } from '../types/area'
import type { User } from '../types/user'
import { Drawer, Button, useToast, useConfirm, MultiSelect } from 'primevue'
import { ref } from 'vue'
import { useAreaStore } from '../stores/areaStore'
import { useUsersStore } from '../stores/usersStore'
const areaStore = useAreaStore()
const usersStore = useUsersStore()
const confirm = useConfirm()
const toast = useToast()
interface NewArea {
_id?: string
type: 'Polygon'
geometry: {
type: 'Polygon'
coordinates: Array<Array<Array<number>>>
}
owners?: User[] | string[]
office: string
}
const isVisible = ref(false)
const newArea = ref<NewArea>({
_id: areaStore.selectedArea?._id || '',
type: 'Polygon',
geometry: {
type: 'Polygon',
coordinates: areaStore.selectedArea?.geometry.coordinates || [[[]]],
},
office: areaStore.selectedArea?.office || '',
owners: areaStore.selectedArea?.owners
? areaStore.selectedArea.owners
.map((owner) => (typeof owner === 'object' && '_id' in owner ? owner._id : owner))
.filter((id): id is string => Boolean(id))
: [],
})
const toggleDrawerVisible = () => {
isVisible.value = !isVisible.value
}
defineExpose({
toggleDrawerVisible,
})
const confirmDeleteArea = () => {
confirm.require({
message: 'Are you sure you want to delete this area?',
header: 'Delete?',
icon: 'pi pi-trash',
rejectLabel: 'Cancel',
rejectProps: {
label: 'Cancel',
severity: 'secondary',
outlined: true,
},
rejectIcon: 'pi pi-times-circle',
acceptProps: {
label: 'Delete',
severity: 'danger',
},
acceptIcon: 'pi pi-trash',
accept: async () => {
const success = await areaStore.deleteArea(areaStore.selectedArea?._id)
if (!success) {
toast.add({
severity: 'error',
summary: 'Error!',
detail: 'There was a problem deleting the area!',
life: 2500,
})
return
}
toast.add({
severity: 'secondary',
summary: 'Deleted!',
detail: 'Deleted Area!',
life: 2500,
})
toggleDrawerVisible()
},
reject: () => {},
})
}
const getFullName = (option: User) => {
return `${option.firstName} ${option.lastName}`
}
const handleShow = () => {
newArea.value = {
_id: areaStore.selectedArea?._id || '',
type: 'Polygon',
geometry: {
type: 'Polygon',
coordinates: areaStore.selectedArea?.geometry.coordinates || [[[]]],
},
office: areaStore.selectedArea?.office || '',
owners: areaStore.selectedArea?.owners
? areaStore.selectedArea.owners
.map((owner) => (typeof owner === 'object' && '_id' in owner ? owner._id : owner))
.filter((id): id is string => Boolean(id))
: [],
}
}
const handleUpdateArea = async () => {
const success = await areaStore.updateArea(newArea.value as Area)
if (success) {
toast.add({
severity: 'secondary',
summary: 'Updated!',
detail: 'Updated Area!',
life: 2500,
})
toggleDrawerVisible()
} else if (!success) {
toast.add({
severity: 'error',
summary: 'Error!',
detail: 'Error Updating Area!',
life: 2500,
})
}
}
</script>
<template>
<Drawer
@show="handleShow"
v-model:visible="isVisible"
header="Area"
position="bottom"
style="height: auto"
>
<div class="form-group">
<h3>Assigned To:</h3>
<MultiSelect
placeholder="Select Assigned Users"
filter
v-model="newArea.owners"
:options="usersStore.allUsers"
optionValue="_id"
:optionLabel="getFullName"
/>
</div>
<div class="option-container">
<Button @click="confirmDeleteArea" icon="pi pi-trash" severity="danger" label="Delete" />
<Button @click="handleUpdateArea" severity="success" icon="pi pi-save" label="Save" />
</div>
</Drawer>
</template>

View File

@ -0,0 +1,171 @@
<script setup lang="ts">
import { Form, type FormResolverOptions, type FormSubmitEvent } from '@primevue/forms'
import { InputText, Message, Button, Toast, ConfirmDialog } from 'primevue'
import { ref } from 'vue'
import { useOfficeStore } from '../stores/officeStore'
import type { Office } from '../types/office'
import { useRouter } from 'vue-router'
import { useToast } from 'primevue/usetoast'
const toast = useToast()
const officeStore = useOfficeStore()
const router = useRouter()
const props = defineProps<{
office: Office | undefined
}>()
const newOffice = ref()
interface FormErrors {
name?: { message: string }[]
city?: { message: string }[]
state?: { message: string }[]
}
let changed = {
name: false,
city: false,
state: false,
}
const resolver = ({ values }: FormResolverOptions) => {
const errors: FormErrors = {}
// Name Validation
if (changed.name) {
if (!values.name) {
errors.name = [{ message: 'Name is required' }]
} else if (values.name.length < 5) {
errors.name = [{ message: 'Name must be at least 5 characters long!' }]
} else if (values.name.length > 25) {
errors.name = [{ message: 'Name must be less than 25 characters long!' }]
} else if (officeStore.allOffices.some((office) => office.name === values.name)) {
errors.name = [{ message: 'Another Team Exists With That Name!' }]
}
}
// City Validation
if (changed.city) {
if (!values.city) {
errors.city = [{ message: 'City is required' }]
} else if (values.city.length < 3) {
errors.city = [{ message: 'City must be at least 3 characters long!' }]
} else if (values.city.length > 25) {
errors.city = [{ message: 'City must be less than 25 characters long!' }]
}
}
// State Validation
if (changed.state) {
if (!values.state) {
errors.state = [{ message: 'State is required' }]
} else if (values.state.length < 2) {
errors.state = [{ message: 'State must be at least 2 characters long!' }]
} else if (values.state.length > 25) {
errors.state = [{ message: 'State must be less than 25 characters long!' }]
}
}
return {
values,
errors,
}
}
const onFormSubmit = async (form: FormSubmitEvent) => {
if (form.valid) {
console.log('Form VALID!', form.values)
newOffice.value = form.values as Office
newOffice.value._id = props.office?._id
const success = await officeStore.updateOffice(newOffice.value as Office)
if (success) {
toast.add({
severity: 'contrast',
summary: 'Updated Team!',
detail: 'Team Updated and Saved!',
life: 2200,
})
changed.city = false
changed.name = false
changed.state = false
setTimeout(() => {
router.push('/teams')
}, 3000)
} else if (!success) {
toast.add({
severity: 'error',
summary: 'Error Updated Team!',
detail: 'There was an issue updating the team!',
life: 2200,
})
}
}
}
const handleNameChange = () => {
changed.name = true
console.log('changed name')
}
const handleCityChanged = () => {
changed.city = true
console.log('changed City')
}
const handleStateChanged = () => {
changed.state = true
console.log('changed State')
}
</script>
<template>
<ConfirmDialog />
<Toast style="max-width: 300px" />
<Form v-slot="$form" :initialValues="props.office" :resolver @submit="onFormSubmit">
<div class="form-group">
<h3>Team Name</h3>
<InputText
@value-change="handleNameChange"
name="name"
type="text"
placeholder="Team Name"
fluid
/>
<Message v-if="$form.name?.invalid" severity="error" variant="simple">{{
$form.name?.error?.message
}}</Message>
</div>
<div class="form-group">
<h3>City</h3>
<InputText
@value-change="handleCityChanged"
name="city"
type="text"
placeholder="City"
fluid
/>
<Message v-if="$form.city?.invalid" severity="error" variant="simple">{{
$form.city?.error?.message
}}</Message>
</div>
<div class="form-group">
<h3>State</h3>
<InputText
@value-change="handleStateChanged"
name="state"
type="text"
placeholder="State"
fluid
/>
<Message v-if="$form.state?.invalid" severity="error" variant="simple">{{
$form.state?.error?.message
}}</Message>
</div>
<div class="form-buttons">
<Button type="submit" severity="info" label="Save" icon="pi pi-save" />
</div>
</Form>
</template>

View File

@ -0,0 +1,366 @@
<script setup lang="ts">
import { Form, type FormResolverOptions, type FormSubmitEvent } from '@primevue/forms'
import {
InputText,
Message,
Button,
ScrollPanel,
Select,
MultiSelect,
useToast,
useConfirm,
} from 'primevue'
import { ref, onMounted } from 'vue'
import { useAxios } from '@vueuse/integrations/useAxios'
import { useUsersStore } from '../stores/usersStore'
import { useAuthStore } from '../stores/authStore'
import type { User } from '../types/user'
import { useRouter, useRoute } from 'vue-router'
import type { Office } from '../types/office'
const toast = useToast()
const confirm = useConfirm()
const usersStore = useUsersStore()
const authStore = useAuthStore()
const router = useRouter()
const route = useRoute()
const usernameAvailable = ref(true)
const userId = ref()
const usernameChanged = ref(false)
const passwordChanged = ref(false)
interface Role {
name: string
value: number
}
const props = defineProps<{
possibleOffices: Office[]
possibleRoles: Role[]
possibleSupervisors: User[]
}>()
interface FormValues {
username: string
city: string
state: string
}
interface FormErrors {
username?: { message: string }[]
password?: { message: string }[]
firstName?: { message: string }[]
lastName?: { message: string }[]
role?: { message: string }[]
offices?: { message: string }[]
supervisor?: { message: string }[]
}
interface FormUser {
_id?: string
username: string
password?: string
firstName: string
lastName: string
createdAt?: Date
role: number
offices: Office[] | string[]
supervisor?: User | string
}
const newUser = ref<FormUser>({
_id: usersStore.activeViewedUser._id || '',
username: usersStore.activeViewedUser.username || '',
firstName: usersStore.activeViewedUser.firstName || '',
lastName: usersStore.activeViewedUser.lastName || '',
role: usersStore.activeViewedUser.role || 0,
offices: usersStore.activeViewedUser.offices
? usersStore.activeViewedUser.offices.map((office) => office._id as string)
: [],
supervisor: usersStore.activeViewedUser.supervisor?._id,
})
const isUsernameAvailable = async (username: string) => {
const { data, error } = await useAxios(
`https://${authStore.company}.justcanvas.app/api/v1/users/check-username/${username}`,
{
method: 'GET',
withCredentials: true,
}
)
if (error.value) {
console.log('error checking username', error.value)
throw error.value
}
return data.value.available
}
const getFullName = (option: User) => {
return `${option.firstName} ${option.lastName}`
}
const resolver = ({ values }: FormResolverOptions) => {
const errors: FormErrors = {}
// Username Validation
if (!values.username) {
errors.username = [{ message: 'Username is required' }]
} else if (values.username.includes(' ')) {
errors.username = [{ message: 'Username cannot contain spaces!' }]
} else if (values.username.length < 5) {
errors.username = [{ message: 'username must be at least 5 characters long!' }]
} else if (values.username.length > 25) {
errors.username = [{ message: 'username must be less than 25 characters long!' }]
}
// FirstName Validation
if (!values.firstName) {
errors.firstName = [{ message: 'First Name is required' }]
} else if (values.firstName.length > 25) {
errors.firstName = [{ message: 'First Name must be less than 25 characters long!' }]
}
// Passsword Validation
if (passwordChanged.value === true && values.password.length < 5) {
errors.password = [{ message: 'Password must be at least 5 characters long!' }]
}
// LastName Validation
if (!values.lastName) {
errors.lastName = [{ message: 'Last Name is required' }]
} else if (values.lastName.length > 25) {
errors.lastName = [{ message: 'Last Name must be less than 25 characters long!' }]
}
//Teams Validation
if (values.offices.length === 0) {
errors.offices = [{ message: 'At least one team is required!' }]
}
return {
values,
errors,
}
}
const onFormSubmit = async (form: FormSubmitEvent) => {
if (form.valid) {
console.log('Form VALID!', form.values)
let usernameIsAvailable = false
if (usernameChanged.value === true) {
usernameIsAvailable = await isUsernameAvailable(form.values.username)
} else if (usernameChanged.value === false) {
usernameIsAvailable = true
}
if (usernameIsAvailable === false) {
usernameAvailable.value = false
return
}
usernameAvailable.value = true
usernameChanged.value = false
passwordChanged.value = false
await usersStore.updateUser(newUser.value as User)
console.log('Submitted USER:', newUser.value as User)
toast.add({
severity: 'contrast',
summary: 'Updated User',
detail: 'User Updated and Saved!',
life: 2200,
})
setTimeout(() => {
router.push('/users')
}, 3000)
}
}
const confirmDeleteUser = () => {
confirm.require({
message: 'Are you sure you want to delete this user?',
header: 'Delete?',
icon: 'pi pi-trash',
rejectLabel: 'Cancel',
rejectProps: {
label: 'Cancel',
severity: 'secondary',
outlined: true,
},
rejectIcon: 'pi pi-times-circle',
acceptProps: {
label: 'Delete',
severity: 'danger',
},
acceptIcon: 'pi pi-trash',
accept: async () => {
const success = await usersStore.deleteUser(newUser.value._id)
if (!success) {
toast.add({
severity: 'error',
summary: 'Error!',
detail: 'There was a problem deleting the user!',
life: 2500,
})
return
}
toast.add({
severity: 'secondary',
summary: 'Deleted!',
detail: 'Deleted User!',
life: 2500,
})
setTimeout(() => {
router.back()
}, 2650)
},
reject: () => {},
})
}
const handlePasswordChanged = () => {
console.log('Password Changed')
passwordChanged.value = true
}
const handleUsernameChanged = () => {
console.log('Username Changed')
usernameChanged.value = true
}
onMounted(async () => {
userId.value = route.params.id
usersStore.activeViewedUser = await usersStore.fetchUserFromId(userId.value)
})
</script>
<template>
<ScrollPanel style="width: 100%; height: 100%">
<Form v-slot="$form" :initialValues="newUser" :resolver @submit="onFormSubmit">
<div class="form-group" v-motion-pop-visible>
<h3>Username</h3>
<InputText
@update:modelValue="handleUsernameChanged"
name="username"
v-model="newUser.username"
type="text"
placeholder="Username"
fluid
/>
<Message v-if="$form.username?.invalid" severity="error" variant="simple">{{
$form.username?.error?.message
}}</Message>
<Message v-if="!usernameAvailable" severity="error" variant="simple"
>That Username is Already Taken!</Message
>
</div>
<div class="form-group" v-motion-pop-visible>
<h3>New Password</h3>
<InputText
@value-change="handlePasswordChanged"
name="password"
v-model="newUser.password"
type="password"
placeholder="Password"
fluid
/>
<Message v-if="$form.password?.invalid" severity="error" variant="simple">{{
$form.password?.error?.message
}}</Message>
</div>
<div class="form-group" v-motion-pop-visible>
<h3>First Name</h3>
<InputText
name="firstName"
v-model="newUser.firstName"
type="text"
placeholder="First Name"
fluid
/>
<Message v-if="$form.firstName?.invalid" severity="error" variant="simple">{{
$form.firstName?.error?.message
}}</Message>
</div>
<div class="form-group" v-motion-pop-visible>
<h3>Last Name</h3>
<InputText
name="lastName"
v-model="newUser.lastName"
type="text"
placeholder="Last Name"
fluid
/>
<Message v-if="$form.lastName?.invalid" severity="error" variant="simple">{{
$form.lastName?.error?.message
}}</Message>
</div>
<div class="form-group" v-motion-pop-visible>
<h3>Role</h3>
<Select
name="role"
v-model="newUser.role"
:options="props.possibleRoles"
optionValue="value"
optionLabel="name"
fluid
></Select>
<Message v-if="$form.role?.invalid" severity="error" variant="simple">{{
$form.role?.error?.message
}}</Message>
</div>
<div class="form-group" v-motion-pop-visible>
<h3>Teams</h3>
<MultiSelect
placeholder="Select Assigned Teams"
name="offices"
filter
v-model="newUser.offices"
optionLabel="name"
:options="props.possibleOffices"
optionValue="_id"
style="display: flex"
></MultiSelect>
<Message v-if="$form.offices?.invalid" severity="error" variant="simple">{{
$form.offices?.error?.message
}}</Message>
</div>
<div class="form-group" v-motion-pop-visible>
<h3>Supervisor</h3>
<Select
:options="possibleSupervisors"
filter
v-model="newUser.supervisor"
name="supervisor"
placeholder="Select a Supervisor"
optionValue="_id"
:optionLabel="getFullName"
></Select>
<Message v-if="$form.supervisor?.invalid" severity="error" variant="simple">{{
$form.supervisor?.error?.message
}}</Message>
</div>
<div class="option-container more-space" v-motion-pop-visible>
<Button
v-if="newUser._id !== authStore.user._id"
severity="danger"
@click="confirmDeleteUser"
label="Delete"
icon="pi pi-trash"
/>
<Button type="submit" severity="success" label="Save" icon="pi pi-save" />
</div>
</Form>
</ScrollPanel>
</template>

View File

@ -0,0 +1,156 @@
<script setup lang="ts">
import type { Pin } from '../types/pin'
import type { Area } from '../types/area'
import { Drawer, Button, useToast, useConfirm } from 'primevue'
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { usePinStore } from '../stores/pinStore'
import { useAreaStore } from '../stores/areaStore'
import { useUsersStore } from '../stores/usersStore'
const router = useRouter()
const pinStore = usePinStore()
const areaStore = useAreaStore()
const usersStore = useUsersStore()
const confirm = useConfirm()
const toast = useToast()
const isVisible = ref(false)
const toggleDrawerVisible = () => {
isVisible.value = !isVisible.value
}
defineExpose({
toggleDrawerVisible,
})
const props = defineProps<{
selectedPin: Pin | undefined
selectedArea: Area | undefined
mode: 'Pin' | 'Area'
}>()
const emit = defineEmits<{
(event: 'edit-area'): void
}>()
const handleViewClick = async () => {
if (props.mode === 'Pin') {
pinStore.selectedPin = props.selectedPin
router.push(`/pin/${props.selectedPin?._id}`)
} else if (props.mode === 'Area') {
areaStore.selectedArea = await areaStore.fetchSingleArea(props.selectedArea?._id)
await usersStore.fetchAllUsersInActiveOffice()
console.log('Selected AREA:', areaStore.selectedArea)
toggleDrawerVisible()
emit('edit-area')
}
}
const confirmDeletePin = () => {
confirm.require({
message: 'Are you sure you want to delete this pin?',
header: 'Delete?',
icon: 'pi pi-trash',
rejectLabel: 'Cancel',
rejectProps: {
label: 'Cancel',
severity: 'secondary',
outlined: true,
},
rejectIcon: 'pi pi-times-circle',
acceptProps: {
label: 'Delete',
severity: 'danger',
},
acceptIcon: 'pi pi-trash',
accept: async () => {
const success = await pinStore.deletePin(props.selectedPin?._id)
if (!success) {
toast.add({
severity: 'error',
summary: 'Error!',
detail: 'There was a problem deleting the pin!',
life: 2500,
})
return
}
toast.add({
severity: 'secondary',
summary: 'Deleted!',
detail: 'Deleted Pin!',
life: 2500,
})
},
reject: () => {},
})
}
const confirmDeleteArea = () => {
confirm.require({
message: 'Are you sure you want to delete this area?',
header: 'Delete?',
icon: 'pi pi-trash',
rejectLabel: 'Cancel',
rejectProps: {
label: 'Cancel',
severity: 'secondary',
outlined: true,
},
rejectIcon: 'pi pi-times-circle',
acceptProps: {
label: 'Delete',
severity: 'danger',
},
acceptIcon: 'pi pi-trash',
accept: async () => {
const success = await areaStore.deleteArea(props.selectedArea?._id)
if (!success) {
toast.add({
severity: 'error',
summary: 'Error!',
detail: 'There was a problem deleting the area!',
life: 2500,
})
return
}
toast.add({
severity: 'secondary',
summary: 'Deleted!',
detail: 'Deleted Area!',
life: 2500,
})
},
reject: () => {},
})
}
const handleDeleteClick = () => {
toggleDrawerVisible()
if (props.mode === 'Pin') {
confirmDeletePin()
} else if (props.mode === 'Area') {
confirmDeleteArea()
}
}
</script>
<template>
<Drawer
v-model:visible="isVisible"
:header="mode === `Pin` ? `Pin` : `Area`"
position="top"
style="height: auto"
>
<div class="option-container">
<Button @click="handleDeleteClick" icon="pi pi-trash" severity="danger" label="Delete" />
<Button @click="handleViewClick" severity="info" icon="pi pi-pen-to-square" label="Edit" />
</div>
</Drawer>
</template>

View File

@ -0,0 +1,42 @@
<script setup lang="ts">
import BackButton from './BackButton.vue'
const props = defineProps<{
title: string
}>()
</script>
<template>
<div class="header-container" v-motion-slide-top>
<div class="header-bar">
<h1 class="title-text" style="color: black">{{ props.title }}</h1>
</div>
</div>
<BackButton style="max-height: 40px" />
</template>
<style lang="css" scoped>
.header-bar {
display: flex;
align-items: center;
justify-content: space-around;
text-align: center;
text-justify: center;
width: 100%;
background-color: #34d399;
/* background: linear-gradient(to bottom, #34d399, #468c78); */
box-shadow: 0 10px 8px #181818ab;
}
.header-container {
display: flex;
justify-content: center;
width: 100%;
}
@media (max-width: 805px) {
.title-text {
margin-top: 70px;
}
}
</style>

View File

@ -0,0 +1,273 @@
<script setup lang="ts">
import { View } from 'ol'
import { ref, computed } from 'vue'
import {
locationIcon,
locationIconBlack,
pinRed,
pinBlue,
pinGray,
pinGreen,
pinBlack,
pinWhite,
pinBlank,
} from '../assets/icons'
import type { ObjectEvent } from 'ol/Object'
import type { DrawEvent } from 'ol/interaction/Draw'
import { useSettingsStore } from '../stores/settingsStore'
import { usePinStore } from '../stores/pinStore'
import { useAreaStore } from '../stores/areaStore'
import { useOfficeStore } from '../stores/officeStore'
import { getPinImageFromType } from '../composables/getPinImageFromType'
import type Map from 'ol/Map'
import type { Area } from '../types/area'
import type { Polygon } from 'ol/geom'
import type { Layer } from 'ol/layer'
import { useToast } from 'primevue/usetoast'
const toast = useToast()
const pinStore = usePinStore()
const settingsStore = useSettingsStore()
const areaStore = useAreaStore()
const officeStore = useOfficeStore()
const center = ref(settingsStore.cachedCoords ? settingsStore.cachedCoords : [-90, 40])
const projection = ref('EPSG:4326')
const zoom = ref(15)
const view = ref<View>()
const map = ref<{ map: Map }>()
const position = ref([])
const newArea = ref<Area>({
type: 'Polygon',
geometry: {
type: 'Polygon',
coordinates: [[[0]]],
},
office: officeStore.activeOffice?._id || '',
})
const tileUrl = computed(() => {
return `https://api.maptiler.com/tiles/satellite/{z}/{x}/{y}.jpg?key=${apiKey}`
})
const attributions = computed(() => {
return '&copy; <a href="https://www.maptiler.com/copyright/" target="_blank">MapTiler</a>'
})
const apiKey = 'rfHFqW8F6s58RE0s7tPb'
// const emit = defineEmits<{
// (event: 'draw-area-end'): void
// (event: 'map-moved'): void
// (event: 'moving-stopped'): void
// }>()
// const drawAreaEnd = () => {
// emit('draw-area-end')
// }
// const mapMoved = () => {
// emit('map-moved')
// }
// const movingStopped = () => {
// emit('moving-stopped')
// }
const props = defineProps<{
drawEnable: boolean
areaSelectEnabled: boolean
drawType: string
clickEvent: PointerEvent | null
}>()
let startingViewSet = false
const geoLocChange = (event: ObjectEvent) => {
position.value = event.target.getPosition()
settingsStore.cachedCoords = position.value
if (!startingViewSet) {
view.value?.setCenter(event.target?.getPosition())
startingViewSet = true
}
}
const drawStart = (event: DrawEvent) => {
console.log(areaStore.userAreas)
console.log(event)
}
const drawEnd = (event: DrawEvent) => {
const feature = event.feature
const geometry = feature.getGeometry() as Polygon
newArea.value.geometry.coordinates = geometry?.getCoordinates()
console.log('new area:', newArea.value)
areaStore.saveNewArea(newArea.value)
toast.add({
severity: 'secondary',
summary: 'Added New Area',
detail: 'New Area Drawn and Saved to the Server!',
life: 3000,
})
//emit('draw-area-end')
}
const determineCoordsFromPixel = (event: PointerEvent | null) => {
if (event !== null) {
const pixel = map.value?.map.getEventPixel(event)
if (pixel) {
const pixelCoords = map.value?.map.getCoordinateFromPixel(pixel)
return pixelCoords
}
}
}
const getFeaturesAtClick = (event: PointerEvent | null) => {
if (event !== null && map.value) {
const pixel = map.value?.map.getEventPixel(event)
if (pixel) {
const feature = map.value?.map.forEachFeatureAtPixel(
pixel,
function (feature) {
return feature
},
{ layerFilter: layerFilter }
)
return feature
}
}
}
const layerFilter = function (layer: Layer) {
return layer.get('className') === (props.areaSelectEnabled ? 'area-layer' : 'pin-layer')
}
defineExpose({
determineCoordsFromPixel,
getFeaturesAtClick,
})
</script>
<template>
<ol-map
style="height: 100%"
:loadTilesWhileAnimating="true"
:loadTilesWhileInteracting="true"
ref="map"
>
<ol-view :center="center" ref="view" :zoom="zoom" :projection="projection"></ol-view>
<ol-tile-layer>
<ol-source-osm />
</ol-tile-layer>
<ol-tile-layer :opacity="settingsStore.mapOpacity">
<ol-source-xyz :url="tileUrl" :attributions="attributions" />
</ol-tile-layer>
<ol-vector-layer className="pin-layer">
<ol-source-vector :projection="projection">
<ol-feature v-for="pin in pinStore.userPins" :key="pin._id">
<ol-geom-point :coordinates="pin.coordinates.coordinates"></ol-geom-point>
<ol-style>
<ol-style-icon :src="getPinImageFromType(pin.type)" :scale="0.065"></ol-style-icon>
</ol-style>
</ol-feature>
</ol-source-vector>
</ol-vector-layer>
<ol-vector-layer className="area-layer">
<ol-source-vector :projection="projection">
<ol-feature v-for="area in areaStore.userAreas" :key="area._id">
<ol-geom-polygon :coordinates="area.geometry.coordinates"></ol-geom-polygon>
<ol-style>
<ol-style-stroke
:color="`#${settingsStore.areaLineColor}`"
:width="2"
></ol-style-stroke>
<ol-style-fill :color="settingsStore.areaFillColor"></ol-style-fill>
</ol-style>
</ol-feature>
</ol-source-vector>
</ol-vector-layer>
<ol-vector-layer>
<ol-source-vector :projection="projection">
<ol-interaction-draw
v-if="props.drawEnable"
:type="props.drawType"
@drawend="drawEnd"
@drawstart="drawStart"
>
<ol-style>
<ol-style-stroke
:color="`#${settingsStore.areaLineColor}`"
:width="2"
></ol-style-stroke>
<ol-style-fill :color="settingsStore.areaFillColor"></ol-style-fill>
<ol-style-circle :radius="5">
<ol-style-fill color="blue" />
<ol-style-stroke color="white" :width="2" />
</ol-style-circle>
</ol-style>
</ol-interaction-draw>
</ol-source-vector>
<ol-style>
<ol-style-stroke color="rgba(0, 0, 0, 0)" :width="2"></ol-style-stroke>
<ol-style-fill color="rgba(0, 0, 0, 0)"></ol-style-fill>
</ol-style>
</ol-vector-layer>
<!-- Geolocation and location Pin layer -->
<ol-geolocation :projection="projection" @change:position="geoLocChange">
<template>
<ol-vector-layer>
<ol-source-vector>
<ol-feature>
<ol-geom-point :coordinates="position"></ol-geom-point>
<ol-style>
<ol-style-icon :src="locationIcon" :scale="0.04"></ol-style-icon>
</ol-style>
</ol-feature>
</ol-source-vector>
</ol-vector-layer>
</template>
</ol-geolocation>
<ol-rotate-control />
</ol-map>
</template>
<style scoped>
.ol-map {
position: relative;
}
.ol-map-loading:after {
content: '';
box-sizing: border-box;
position: absolute;
top: 50%;
left: 50%;
width: 40px;
height: 40px;
margin-top: -20px;
margin-left: -20px;
border-radius: 50%;
border: 5px solid rgba(180, 180, 180, 0.6);
border-top-color: var(--vp-c-brand-1);
animation: spinner 0.6s linear infinite;
}
@keyframes spinner {
to {
transform: rotate(360deg);
}
}
</style>

View File

@ -0,0 +1,40 @@
<script setup lang="ts">
import { Select, Button } from 'primevue'
import type { Office } from '../types/office'
import { ref } from 'vue'
const props = defineProps<{
offices: Office[]
buttonSeverity: string
}>()
const formSubmitted = (selectedOfficeId: string) => {
emit('formSubmitted', selectedOfficeId)
}
const emit = defineEmits<{
(event: 'formSubmitted', officeId: string): void
}>()
const selectedOffice = ref<string | undefined>(props.offices[0]._id || '')
</script>
<template>
<form @submit.prevent="formSubmitted(selectedOffice as string)">
<Select
placeholder="Select Your Active Team"
v-model="selectedOffice"
optionLabel="name"
:options="props.offices"
optionValue="_id"
:defaultValue="props.offices[0]._id"
style="display: flex"
></Select>
<Button
style="margin-left: 70px; margin-top: 40px"
type="submit"
:severity="props.buttonSeverity || 'secondary'"
label="Save"
></Button>
</form>
</template>

View File

@ -0,0 +1,295 @@
<script setup lang="ts">
import { Drawer, InputText, Button } from 'primevue'
import { ref } from 'vue'
import { useAxios } from '@vueuse/integrations/useAxios'
import { computed } from 'vue'
const props = defineProps<{
dialogueVisible: boolean
newPinCoords: number[]
}>()
const apiUrl = computed(
() =>
`https://nominatim.openstreetmap.org/reverse?format=json&lat=${props.newPinCoords[1]}&lon=${props.newPinCoords[0]}`
)
interface pinInfo {
address?: string
city?: string
state?: string
zip?: string
name?: string
phone?: string
email?: string
notes?: string
}
const pinInfo = ref<pinInfo>({
address: '',
city: '',
state: '',
zip: '',
name: '',
phone: '',
email: '',
notes: '',
})
const clickedSave = (pinDetails: pinInfo) => {
emit('clicked-save', pinDetails)
pinInfo.value = {
address: '',
city: '',
state: '',
zip: '',
name: '',
phone: '',
email: '',
notes: '',
}
}
const reverseGeoAddress = async () => {
try {
if (props.newPinCoords[0] !== undefined && props.newPinCoords[1] !== undefined) {
const { data, isLoading, error } = await useAxios(
`https://nominatim.openstreetmap.org/reverse?format=json&lat=${props.newPinCoords[1]}&lon=${props.newPinCoords[0]}`,
{
method: 'GET',
}
)
console.log(data)
if (data.value?.address) {
pinInfo.value.address = data.value.address.house_number + ' ' + data.value.address.road
pinInfo.value.city = data.value.address.city || data.value.address.town
pinInfo.value.state = data.value.address.state
pinInfo.value.zip = data.value.address.postcode
}
} else {
return
}
} catch (err) {
console.error('Error looking up address:', err)
}
}
const emit = defineEmits<{
(event: 'clicked-save', pinInfo: pinInfo): void
(event: 'clicked-close'): void
}>()
const handleClickedSave = () => {
clickedSave(pinInfo.value)
}
const handleClickedClose = () => {
emit('clicked-close')
}
</script>
<template>
<Drawer
:visible="props.dialogueVisible"
@show="reverseGeoAddress"
header="Pin Details"
position="top"
style="height: auto"
:block-scroll="true"
>
<template #container="{ closeCallback }">
<div
class="pin-window"
style="
display: flex;
flex-direction: column;
justify-content: space-between;
width: auto;
align-items: center;
"
>
<h3>Pin Details</h3>
<div class="flex input-group">
<label for="pinInfo.name" class="font-semibold w-24">Name</label>
<InputText
size="large"
v-model="pinInfo.name"
id="pinInfo.name"
class="input-text"
autocomplete="off"
/>
</div>
<div class="flex input-group">
<label for="pinInfo.email" class="font-semibold w-24">Email</label>
<InputText
size="large"
v-model="pinInfo.email"
id="pinInfo.email"
class="input-text"
autocomplete="off"
/>
</div>
<div class="flex input-group">
<label for="pinInfo.phone" class="font-semibold w-24">Phone</label>
<InputText
size="large"
v-model="pinInfo.phone"
id="pinInfo.phone"
class="input-text"
autocomplete="off"
/>
</div>
<div class="flex input-group">
<label for="pinInfo.address" class="font-semibold w-24">Address</label>
<InputText
v-model="pinInfo.address"
id="pinInfo.address"
class="input-text"
autocomplete="off"
size="large"
/>
</div>
<div class="flex input-group">
<label for="pinInfo.city" class="font-semibold w-24">City</label>
<InputText
v-model="pinInfo.city"
id="pinInfo.city"
class="input-text"
autocomplete="off"
size="large"
/>
</div>
<div class="flex input-group">
<label for="pinInfo.state" class="font-semibold w-24">State</label>
<InputText
v-model="pinInfo.state"
id="pinInfo.state"
class="input-text"
autocomplete="off"
size="large"
/>
</div>
<div class="flex input-group">
<label for="pinInfo.zip" class="font-semibold w-24">Zip</label>
<InputText
v-model="pinInfo.zip"
id="pinInfo.zip"
class="input-text"
autocomplete="off"
size="large"
/>
</div>
<div class="flex input-group">
<label for="pinInfo.notes" class="font-semibold w-24">Notes</label>
<InputText
size="large"
v-model="pinInfo.notes"
id="pinInfo.notes"
class="input-text"
autocomplete="off"
/>
</div>
<div class="button-group">
<Button
type="button"
label="Cancel"
severity="danger"
@click="handleClickedClose"
></Button>
<Button type="button" label="Save" @click="handleClickedSave"></Button>
</div>
</div>
</template>
</Drawer>
<!-- <Dialog
v-model:visible="props.dialogueVisible"
modal
header="Pin Details"
:style="{ width: '20rem' }"
>
<div class="flex input-group">
<label for="pinInfo.name" class="font-semibold w-24">Name</label>
<InputText
size="small"
v-model="pinInfo.name"
id="pinInfo.name"
class="flex-auto"
autocomplete="off"
/>
</div>
<div class="flex input-group">
<label for="pinInfo.email" class="font-semibold w-24">Email</label>
<InputText
size="small"
v-model="pinInfo.email"
id="pinInfo.email"
class="flex-auto"
autocomplete="off"
/>
</div>
<div class="flex input-group">
<label for="pinInfo.phone" class="font-semibold w-24">Phone</label>
<InputText
size="small"
v-model="pinInfo.phone"
id="pinInfo.phone"
class="flex-auto"
autocomplete="off"
/>
</div>
<div class="flex input-group">
<label for="pinInfo.address" class="font-semibold w-24">Address</label>
<InputText
v-model="pinInfo.address"
id="pinInfo.address"
class="flex-auto"
autocomplete="off"
size="small"
/>
</div>
<div class="flex input-group">
<label for="pinInfo.notes" class="font-semibold w-24">Notes</label>
<InputText
size="small"
v-model="pinInfo.notes"
id="pinInfo.notes"
class="flex-auto"
autocomplete="off"
/>
</div>
<div class="button-group">
<Button
type="button"
label="Cancel"
severity="secondary"
@click="handleClickedClose"
></Button>
<Button type="button" label="Save" @click="handleClickedSave"></Button>
</div>
</Dialog> -->
</template>
<style lang="css" scoped>
.input-group {
display: flex;
padding: 10px;
width: 100%;
justify-content: space-around;
align-items: center;
}
.input-group label {
width: 30%;
font-size: large;
}
.input-text {
width: 70%;
font-size: large;
}
.button-group {
padding: 25px;
display: flex;
width: 100%;
justify-content: space-around;
}
</style>

View File

@ -0,0 +1,120 @@
<script setup lang="ts">
import { Drawer, InputNumber, Select, InputText } from 'primevue'
import { ref } from 'vue'
interface filterSettings {
age?: number
type?: string
zip?: number
state?: string
city?: string
}
const typeOptions = ['Not Interested', 'Not Home', 'No Knock', 'Pitched', 'Go Back', 'Sale']
const isVisible = ref(false)
const filterSettings = ref<filterSettings>({})
const anySettingChanged = ref(false)
const toggleDrawerVisible = () => {
isVisible.value = !isVisible.value
}
const emit = defineEmits<{
(event: 'filters-changed', newFilters: filterSettings): void
}>()
defineExpose({
toggleDrawerVisible,
})
const handleHide = () => {
if (anySettingChanged.value) {
emit('filters-changed', filterSettings.value)
anySettingChanged.value = false
}
}
const handleZipChange = () => {
console.log('ZIP CHANGED')
anySettingChanged.value = true
}
const handleCityChange = () => {
console.log('CITY CHANGED')
anySettingChanged.value = true
}
const handleStateChange = () => {
console.log('STATE CHANGED')
anySettingChanged.value = true
}
const handleAgeChange = () => {
console.log('AGE CHANGED')
anySettingChanged.value = true
}
const handleTypeChange = () => {
console.log('TYPE CHANGED')
anySettingChanged.value = true
}
</script>
<template>
<Drawer @hide="handleHide" position="right" v-model:visible="isVisible">
<div style="padding: 5px; display: flex; flex-direction: column" v-motion-roll-right>
<p>Only Show Pins This Many Days Old</p>
<InputNumber
@value-change="handleAgeChange"
placeholder="Days Old (1-40)"
v-model="filterSettings.age"
size="large"
input-id="integeronly"
:min="1"
:max="40"
/>
</div>
<div style="padding: 5px; display: flex; flex-direction: column" v-motion-roll-right>
<p>Type</p>
<Select
v-model="filterSettings.type"
@value-change="handleTypeChange"
:options="typeOptions"
/>
</div>
<div style="padding: 5px; display: flex; flex-direction: column" v-motion-roll-right>
<p>City</p>
<InputText
@value-change="handleCityChange"
placeholder="City"
v-model="filterSettings.city"
size="large"
type="text"
fluid
/>
</div>
<div style="padding: 5px; display: flex; flex-direction: column" v-motion-roll-right>
<p>State</p>
<InputText
@value-change="handleStateChange"
placeholder="State"
v-model="filterSettings.state"
size="large"
type="text"
fluid
/>
</div>
<div style="padding: 5px; display: flex; flex-direction: column" v-motion-roll-right>
<p>Zip</p>
<InputNumber
@value-change="handleZipChange"
placeholder="Zip Code"
v-model="filterSettings.zip"
size="large"
input-id="integeronly"
:useGrouping="false"
/>
</div>
</Drawer>
</template>

View File

@ -0,0 +1,31 @@
<script setup lang="ts">
import { Button } from 'primevue'
import { useRouter } from 'vue-router'
const props = defineProps<{
userId: string | undefined
}>()
const router = useRouter()
const handleBackClick = () => {
router.push(`/pin-history/${props.userId}`)
}
</script>
<template>
<Button
@click="handleBackClick"
v-motion-slide-right
severity="help"
label="Pin History"
icon="pi pi-history"
></Button>
</template>
<style lang="css" scoped>
Button {
position: absolute;
right: 10%;
top: 3%;
}
</style>

View File

@ -0,0 +1,33 @@
<script setup lang="ts">
import { ScrollPanel } from 'primevue'
import SinglePinListItem from './SinglePinListItem.vue'
import { onMounted, ref, watch } from 'vue'
import type { Pin } from '../types/pin'
const props = defineProps<{
userPins: Pin[]
}>()
const pins = ref<Pin[]>([])
const pinDeleted = () => {
emit('pin-deleted')
}
const emit = defineEmits<{
(event: 'pin-deleted'): void
}>()
onMounted(() => {
pins.value = props.userPins
})
</script>
<template>
<ScrollPanel style="width: 100%; height: 100%">
<SinglePinListItem
@pin-deleted="pinDeleted"
v-for="pin in userPins"
:key="pin._id"
:pin="pin"
/>
</ScrollPanel>
</template>

View File

@ -0,0 +1,69 @@
<script setup lang="ts">
import { Drawer, InputNumber } from 'primevue'
import { ref } from 'vue'
import { useSettingsStore } from '../stores/settingsStore'
const settingsStore = useSettingsStore()
const settingChanged = ref(false)
const isVisible = ref(false)
const ageFilter = ref<number>(parseInt(settingsStore.pinAgeFilter || '2'))
const toggleDrawerVisible = () => {
isVisible.value = !isVisible.value
}
defineExpose({
toggleDrawerVisible,
})
const checkSettingsChanged = () => {
if (settingChanged.value) {
settingsStore.pinAgeFilter = ageFilter.value.toString()
settingChanged.value = false
console.log(settingsStore.pinAgeFilter)
emit('settings-changed')
return
}
settingChanged.value = false
}
const handleValueChanged = () => {
settingChanged.value = true
}
const emit = defineEmits<{
(event: 'settings-changed'): void
}>()
</script>
<template>
<Drawer
@hide="checkSettingsChanged"
v-model:visible="isVisible"
header="Pin Settings"
position="bottom"
style="height: auto"
>
<div style="padding: 10px">
<h3>Show Pins</h3>
<div style="display: flex; padding: 5px; justify-content: left; align-items: center">
<InputNumber
@update:model-value="handleValueChanged"
size="large"
v-model="ageFilter"
input-id="integeronly"
:min="1"
:max="40"
></InputNumber>
<p style="margin-left: 3%">Days Old</p>
</div>
</div>
</Drawer>
</template>
<style lang="css" scoped>
Drawer {
z-index: 1300;
}
</style>

View File

@ -0,0 +1,129 @@
<script setup lang="ts">
import { SpeedDial, Button } from 'primevue'
import { ref } from 'vue'
import { pinBlue, pinGreen, pinGray, pinBlack, pinYellow, pinRed } from '../assets/icons'
const menuItems = ref([
{
label: 'Not Interested',
icon: pinRed,
style:
'--p-button-primary-background: #a0231f80; --p-button-primary-border-color: #a0231f80; --p-button-primary-hover-background: #ca403cce; --p-button-primary-hover-border-color: #ca403cce;',
command: () => {
clickedPin('Not Interested')
},
},
{
label: 'Pitched',
icon: pinBlue,
style:
'--p-button-primary-background: #2a758b7c; --p-button-primary-border-color: #2a758b7c; --p-button-primary-hover-background: #29819cc4; --p-button-primary-hover-border-color: #29819cc4;',
command: () => {
clickedPin('Pitched')
},
},
{
label: 'Sale',
icon: pinGreen,
style:
'--p-button-primary-background: #4c923e9d; --p-button-primary-border-color: #4c923e9d; --p-button-primary-hover-background: #67be56d2; --p-button-primary-hover-border-color: #67be56d2;',
command: () => {
clickedPin('Sale')
},
},
{
label: 'Go Back',
icon: pinYellow,
style:
'--p-button-primary-background: #95971591; --p-button-primary-border-color: #95971591; --p-button-primary-hover-background: #c7c948da; --p-button-primary-hover-border-color: #c7c948da;',
command: () => {
clickedPin('Go Back')
},
},
{
label: 'Not Home',
icon: pinGray,
style:
'--p-button-primary-background: #00000096; --p-button-primary-border-color: #00000096; --p-button-primary-hover-background: #242424e3; --p-button-primary-hover-border-color: #242424e3;',
command: () => {
clickedPin('Not Home')
},
},
{
label: 'No Knock',
icon: pinBlack,
style:
'--p-button-primary-background: #58585886; --p-button-primary-border-color: #58585886; --p-button-primary-hover-background: #464646c2; --p-button-primary-hover-border-color: #464646c2;',
command: () => {
clickedPin('No Knock')
},
},
])
const clickedClose = () => {
emit('clickedClose')
}
const props = defineProps({
visibility: {
type: Boolean,
default: false,
},
})
const menuHidden = () => {
emit('menuHidden')
}
const clickedPin = (pinType: string) => {
emit('clicked-pin', pinType)
}
const emit = defineEmits<{
(event: 'clickedClose'): void
(event: 'menuHidden'): void
(event: 'clicked-pin', pinType: string): void
}>()
</script>
<template>
<SpeedDial
:visible="props.visibility"
:model="menuItems"
type="circle"
:radius="70"
:hideOnClickOutside="false"
class="floating-button"
:transitionDelay="0"
style="position: absolute; left: calc(50%); top: calc(50%)"
@click="clickedClose"
@hide="menuHidden"
>
<template #item="{ item, toggleCallback }">
<div>
<Button
raised
severity="primary"
@click="toggleCallback"
class="radial-button pi p-button-rounded p-button-animated"
:style="item.style"
>
<img :src="item.icon" style="width: 14px" />
</Button>
</div>
</template>
</SpeedDial>
</template>
<style lang="css" scoped>
.radial-button {
width: 48px;
}
button {
--p-button-primary-background: #242424e3;
--p-button-primary-border-color: yellow;
--p-button-primary-hover-background: purple;
--p-button-primary-hover-border-color: blue;
}
</style>

View File

@ -0,0 +1,92 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import SpeedDial from 'primevue/speeddial'
import { Button } from 'primevue'
import { useRouter } from 'vue-router'
const router = useRouter()
const clickedClose = () => {
emit('clickedEdit')
}
const clickedMap = () => {
emit('clicked-map')
}
const clickedPin = () => {
emit('clicked-pin')
}
const emit = defineEmits<{
(event: 'clickedEdit'): void
(event: 'clicked-map'): void
(event: 'clicked-pin'): void
}>()
const menuItems = ref([
{
label: 'Map',
icon: 'pi pi-map',
command: () => Promise.resolve(clickedMap()),
},
{
label: 'Pin Filter',
icon: 'pi pi-thumbtack',
command: () => Promise.resolve(clickedPin()),
},
])
const props = defineProps({
areaPermissions: {
type: Boolean,
default: false,
},
})
const areaMenuEntry = {
label: 'Edit Area',
icon: 'pi pi-pen-to-square',
command: () => Promise.resolve(clickedClose()),
}
const addAreaPermission = () => {
if (
props.areaPermissions === true &&
!menuItems.value.find((entry) => entry.label === areaMenuEntry.label)
) {
menuItems.value.push(areaMenuEntry)
}
}
const clickCallback = (toggleCallback: Function) => {
toggleCallback()
addAreaPermission()
}
</script>
<template>
<SpeedDial
:model="menuItems"
direction="up"
style="position: absolute; bottom: calc(10% - 2rem); right: calc(10% - 1rem)"
class="floatin-button"
>
<template #button="{ toggleCallback }">
<Button
class="pi p-button-rounded p-button-animated shadow-button"
@click="clickCallback(toggleCallback)"
style="padding: 10px"
v-motion-slide-right
>
<i class="pi pi-cog"></i>
</Button>
</template>
</SpeedDial>
</template>
<style>
.floating-button {
z-index: 1200;
}
</style>

View File

@ -0,0 +1,100 @@
<script setup lang="ts">
import { Drawer, Slider, ColorPicker } from 'primevue'
import { ref, computed } from 'vue'
import { useSettingsStore } from '../stores/settingsStore'
import Color from 'color'
const settingsStore = useSettingsStore()
const isVisible = ref(false)
const sliderChanged = ref(false)
const convertedColorRGB = computed(() => {
return Color(`#${colorValue.value}`).rgb().string()
})
const convertedAlphaRGB = computed(() => {
const values = convertedColorRGB.value.match(/\d+/g)
if (values) {
return `rgba(${values[0]}, ${values[1]}, ${values[2]}, 0.15)`
}
})
const colorValue = ref(settingsStore.areaLineColor)
const toggleDrawerVisible = () => {
isVisible.value = !isVisible.value
}
const handleSliderChange = () => {
sliderChanged.value = true
}
const handleColorChange = () => {
sliderChanged.value = true
}
defineExpose({
toggleDrawerVisible,
isVisible,
})
const checkOpacityChanged = () => {
if (sliderChanged.value) {
emit('settings-changed')
console.log('Value WAS CHANGED')
settingsStore.areaLineColor = `${colorValue.value}`
settingsStore.areaFillColor = convertedAlphaRGB.value
sliderChanged.value = false
return
}
sliderChanged.value = false
console.log('NO CHANGE')
}
const emit = defineEmits<{
(event: 'settings-changed'): void
}>()
</script>
<template>
<Drawer
v-model:visible="isVisible"
header="Map Settings"
position="bottom"
style="height: auto"
@hide="checkOpacityChanged"
>
<div style="padding: 10px">
<h3>Map Opacity</h3>
<Slider
:min="0"
:max="1"
:step="0.01"
@change="handleSliderChange"
v-model="settingsStore.mapOpacity"
></Slider>
</div>
<div style="padding: 10px">
<h3>Area Color</h3>
<div style="display: flex; justify-content: center">
<ColorPicker
@change="handleColorChange"
v-model="colorValue"
format="hex"
inline
></ColorPicker>
<div :style="{ backgroundColor: convertedColorRGB }" class="color-preview"></div>
</div>
</div>
</Drawer>
</template>
<style lang="css" scoped>
.color-preview {
margin: 10px;
border-radius: 10px;
width: 40px;
height: 40px;
}
</style>

View File

@ -0,0 +1,91 @@
<script setup lang="ts">
import { Button, useConfirm, useToast } from 'primevue'
import { useOfficeStore } from '../stores/officeStore'
import { useRouter } from 'vue-router'
import type { Office } from '../types/office'
const toast = useToast()
const router = useRouter()
const confirm = useConfirm()
const officeStore = useOfficeStore()
const props = defineProps<{
office: Office
}>()
const confirmDeleteOffice = () => {
confirm.require({
message: 'Are you sure you want to delete this team?',
header: 'Delete?',
icon: 'pi pi-trash',
rejectLabel: 'Cancel',
rejectProps: {
label: 'Cancel',
severity: 'secondary',
outlined: true,
},
rejectIcon: 'pi pi-times-circle',
acceptProps: {
label: 'Delete',
severity: 'danger',
},
acceptIcon: 'pi pi-trash',
accept: async () => {
const success = await officeStore.deleteOffice(props.office._id)
if (!success) {
toast.add({
severity: 'error',
summary: 'Error!',
detail: 'There was a problem deleting the team!',
life: 2500,
})
return
}
toast.add({
severity: 'secondary',
summary: 'Deleted!',
detail: 'Deleted Team!',
life: 2500,
})
},
reject: () => {},
})
}
const editOffice = () => {
router.push(`/teams/${props.office._id}`)
}
</script>
<template>
<div class="pin-entry">
<div class="entry-background">
<div class="info-container" v-motion-pop-visible>
<div
class="entry-container"
style="
display: flex;
justify-content: space-between;
align-items: center;
min-width: 210px;
"
>
<i class="pi pi-building" style="color: lightgray; font-size: 1.5rem" />
<div class="text-container">
<h3>{{ office.name }}</h3>
<p>City: {{ office.city }}</p>
</div>
</div>
<div class="button-container">
<Button severity="danger" @click="confirmDeleteOffice" icon="pi pi-trash" />
<Button severity="info" @click="editOffice" icon="pi pi-user-edit" />
</div>
</div>
</div>
</div>
</template>
<style lang="css" scoped></style>

View File

@ -0,0 +1,13 @@
<script setup lang="ts">
import { Button, InputText } from 'primevue'
import type { Pin } from '../types/pin'
const props = defineProps<{
pin: Pin | undefined
}>()
</script>
<template>
<div class="input-group"></div>
</template>

View File

@ -0,0 +1,117 @@
<script setup lang="ts">
import { pinGreen, pinRed, pinYellow, pinBlack, pinBlue, pinGray } from '../assets/icons'
import { Button, useToast, useConfirm } from 'primevue'
import { useRouter } from 'vue-router'
import { usePinStore } from '../stores/pinStore'
import type { Pin } from '../types/pin'
const router = useRouter()
const pinStore = usePinStore()
const confirm = useConfirm()
const toast = useToast()
const props = defineProps<{
pin: Pin
}>()
const emit = defineEmits<{
(event: 'pin-deleted'): void
}>()
const pinDeleted = () => {
emit('pin-deleted')
}
const pinMap: Record<string, string> = {
'Not Interested': pinRed,
Sale: pinGreen,
Pitched: pinBlue,
'Not Home': pinGray,
'No Knock': pinBlack,
'Go Back': pinYellow,
}
const pinColorMap: Record<string, string> = {
'Not Interested': 'orangered',
Sale: 'green',
Pitched: 'darkturquoise',
'Not Home': 'slategray',
'No Knock': 'black',
'Go Back': 'yellow',
}
const handleEditPinClicked = (id: string | undefined) => {
if (id) {
pinStore.selectedPin = props.pin
router.push(`/pin/${id}`)
}
}
const confirmDeletePin = () => {
confirm.require({
message: 'Are you sure you want to delete this pin?',
header: 'Delete?',
icon: 'pi pi-trash',
rejectLabel: 'Cancel',
rejectProps: {
label: 'Cancel',
severity: 'secondary',
outlined: true,
},
rejectIcon: 'pi pi-times-circle',
acceptProps: {
label: 'Delete',
severity: 'danger',
},
acceptIcon: 'pi pi-trash',
accept: async () => {
const success = await pinStore.deletePin(props.pin?._id)
if (!success) {
toast.add({
severity: 'error',
summary: 'Error!',
detail: 'There was a problem deleting the pin!',
life: 2500,
})
return
}
pinDeleted()
toast.add({
severity: 'secondary',
summary: 'Deleted!',
detail: 'Deleted Pin!',
life: 2500,
})
},
reject: () => {},
})
}
</script>
<template>
<div class="pin-entry">
<div class="entry-background">
<div class="info-container" v-motion-pop-visible>
<div class="entry-container">
<img :src="pinMap[pin.type]" />
<div class="text-container">
<h3 :style="{ color: pinColorMap[pin.type] }">{{ pin.type }}</h3>
<p>{{ new Date(pin.createdAt || Date.now()).toLocaleString() || `` }}</p>
</div>
</div>
<div class="button-container">
<Button @click="confirmDeletePin" severity="danger" icon="pi pi-trash" />
<Button
@click="handleEditPinClicked(pin._id)"
severity="info"
icon="pi pi-pen-to-square"
/>
</div>
</div>
</div>
</div>
</template>
<style lang="css" scoped></style>

View File

@ -0,0 +1,102 @@
<script setup lang="ts">
import { Button, useConfirm, useToast } from 'primevue'
import { useUsersStore } from '../stores/usersStore'
import { useAuthStore } from '../stores/authStore'
import { useRouter } from 'vue-router'
import type { User } from '../types/user'
const confirm = useConfirm()
const toast = useToast()
const userStore = useUsersStore()
const authStore = useAuthStore()
const router = useRouter()
const props = defineProps<{
user: User
}>()
const confirmDeleteUser = () => {
confirm.require({
message: 'Are you sure you want to delete this user?',
header: 'Delete?',
icon: 'pi pi-trash',
rejectLabel: 'Cancel',
rejectProps: {
label: 'Cancel',
severity: 'secondary',
outlined: true,
},
rejectIcon: 'pi pi-times-circle',
acceptProps: {
label: 'Delete',
severity: 'danger',
},
acceptIcon: 'pi pi-trash',
accept: async () => {
const success = await userStore.deleteUser(props.user._id)
if (!success) {
toast.add({
severity: 'error',
summary: 'Error!',
detail: 'There was a problem deleting the user!',
life: 2500,
})
return
}
if (success) {
toast.add({
severity: 'secondary',
summary: 'Deleted!',
detail: 'Deleted User!',
life: 2500,
})
}
},
reject: () => {},
})
}
const handleViewUserClicked = async () => {
userStore.activeViewedUser = await userStore.fetchUserFromId(props.user._id || '')
router.push(`/users/${props.user._id}`)
}
</script>
<template>
<div class="pin-entry">
<div class="entry-background">
<div class="info-container" v-motion-pop-visible>
<div
class="entry-container"
style="
display: flex;
justify-content: space-between;
align-items: center;
min-width: 210px;
"
>
<i class="pi pi-user" style="color: lightgray; font-size: 1.5rem" />
<div class="text-container">
<h3>{{ user.firstName }} {{ user.lastName }}</h3>
<p>{{ user.username }}</p>
</div>
</div>
<div class="button-container">
<Button
v-if="props.user._id !== authStore.user._id"
severity="danger"
icon="pi pi-trash"
@click="confirmDeleteUser"
/>
<Button @click="handleViewUserClicked" severity="info" icon="pi pi-user-edit" />
</div>
</div>
</div>
</div>
</template>
<style lang="css" scoped></style>

368
src/components/mapStyle.css Normal file
View File

@ -0,0 +1,368 @@
:root,
:host {
--ol-background-color: white;
--ol-accent-background-color: #F5F5F5;
--ol-subtle-background-color: rgba(128, 128, 128, 0.25);
--ol-partial-background-color: rgba(255, 255, 255, 0.75);
--ol-foreground-color: #333333;
--ol-subtle-foreground-color: #666666;
--ol-brand-color: #00AAFF;
}
.ol-box {
box-sizing: border-box;
border-radius: 2px;
border: 1.5px solid var(--ol-background-color);
background-color: var(--ol-partial-background-color);
}
.ol-mouse-position {
top: 8px;
right: 8px;
position: absolute;
}
.ol-scale-line {
background: var(--ol-partial-background-color);
border-radius: 4px;
bottom: 8px;
left: 8px;
padding: 2px;
position: absolute;
}
.ol-scale-line-inner {
border: 1px solid var(--ol-subtle-foreground-color);
border-top: none;
color: var(--ol-foreground-color);
font-size: 10px;
text-align: center;
margin: 1px;
will-change: contents, width;
transition: all 0.25s;
}
.ol-scale-bar {
position: absolute;
bottom: 8px;
left: 8px;
}
.ol-scale-bar-inner {
display: flex;
}
.ol-scale-step-marker {
width: 1px;
height: 15px;
background-color: var(--ol-foreground-color);
float: right;
z-index: 10;
}
.ol-scale-step-text {
position: absolute;
bottom: -5px;
font-size: 10px;
z-index: 11;
color: var(--ol-foreground-color);
text-shadow: -1.5px 0 var(--ol-partial-background-color), 0 1.5px var(--ol-partial-background-color), 1.5px 0 var(--ol-partial-background-color), 0 -1.5px var(--ol-partial-background-color);
}
.ol-scale-text {
position: absolute;
font-size: 12px;
text-align: center;
bottom: 25px;
color: var(--ol-foreground-color);
text-shadow: -1.5px 0 var(--ol-partial-background-color), 0 1.5px var(--ol-partial-background-color), 1.5px 0 var(--ol-partial-background-color), 0 -1.5px var(--ol-partial-background-color);
}
.ol-scale-singlebar {
position: relative;
height: 10px;
z-index: 9;
box-sizing: border-box;
border: 1px solid var(--ol-foreground-color);
}
.ol-scale-singlebar-even {
background-color: var(--ol-subtle-foreground-color);
}
.ol-scale-singlebar-odd {
background-color: var(--ol-background-color);
}
.ol-unsupported {
display: none;
}
.ol-viewport,
.ol-unselectable {
-webkit-touch-callout: none;
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
.ol-viewport canvas {
all: unset;
overflow: hidden;
}
.ol-viewport {
touch-action: pan-x pan-y;
}
.ol-selectable {
-webkit-touch-callout: default;
-webkit-user-select: text;
-moz-user-select: text;
user-select: text;
}
.ol-grabbing {
cursor: -webkit-grabbing;
cursor: -moz-grabbing;
cursor: grabbing;
}
.ol-grab {
cursor: move;
cursor: -webkit-grab;
cursor: -moz-grab;
cursor: grab;
}
.ol-control {
position: absolute;
background-color: var(--ol-subtle-background-color);
border-radius: 4px;
}
.ol-zoom {
top: .5em;
left: .5em;
}
.ol-rotate {
top: .5em;
right: .5em;
transition: opacity .25s linear, visibility 0s linear;
}
.ol-rotate.ol-hidden {
opacity: 0;
visibility: hidden;
transition: opacity .25s linear, visibility 0s linear .25s;
}
.ol-zoom-extent {
top: 4.643em;
left: .5em;
}
.ol-full-screen {
right: .5em;
top: .5em;
}
.ol-control button {
display: block;
margin: 1px;
padding: 0;
color: var(--ol-subtle-foreground-color);
font-weight: bold;
text-decoration: none;
font-size: inherit;
text-align: center;
height: 1.375em;
width: 1.375em;
line-height: .4em;
background-color: var(--ol-background-color);
border: none;
border-radius: 2px;
}
.ol-control button::-moz-focus-inner {
border: none;
padding: 0;
}
.ol-zoom-extent button {
line-height: 1.4em;
}
.ol-compass {
display: block;
font-weight: normal;
will-change: transform;
}
.ol-touch .ol-control button {
font-size: 1.5em;
}
.ol-touch .ol-zoom-extent {
top: 5.5em;
}
.ol-control button:hover,
.ol-control button:focus {
text-decoration: none;
outline: 1px solid var(--ol-subtle-foreground-color);
color: var(--ol-foreground-color);
}
.ol-zoom .ol-zoom-in {
border-radius: 2px 2px 0 0;
}
.ol-zoom .ol-zoom-out {
border-radius: 0 0 2px 2px;
}
.ol-attribution {
text-align: right;
bottom: .5em;
right: .5em;
max-width: calc(100% - 1.3em);
display: flex;
flex-flow: row-reverse;
align-items: center;
}
.ol-attribution a {
color: var(--ol-subtle-foreground-color);
text-decoration: none;
}
.ol-attribution ul {
margin: 0;
padding: 1px .5em;
color: var(--ol-foreground-color);
text-shadow: 0 0 2px var(--ol-background-color);
font-size: 12px;
}
.ol-attribution li {
display: inline;
list-style: none;
}
.ol-attribution li:not(:last-child):after {
content: " ";
}
.ol-attribution img {
max-height: 2em;
max-width: inherit;
vertical-align: middle;
}
.ol-attribution button {
flex-shrink: 0;
}
.ol-attribution.ol-collapsed ul {
display: none;
}
.ol-attribution:not(.ol-collapsed) {
background: var(--ol-partial-background-color);
}
.ol-attribution.ol-uncollapsible {
bottom: 0;
right: 0;
border-radius: 4px 0 0;
}
.ol-attribution.ol-uncollapsible img {
margin-top: -.2em;
max-height: 1.6em;
}
.ol-attribution.ol-uncollapsible button {
display: none;
}
.ol-zoomslider {
top: 4.5em;
left: .5em;
height: 200px;
}
.ol-zoomslider button {
position: relative;
height: 10px;
}
.ol-touch .ol-zoomslider {
top: 5.5em;
}
.ol-overviewmap {
left: 0.5em;
bottom: 0.5em;
}
.ol-overviewmap.ol-uncollapsible {
bottom: 0;
left: 0;
border-radius: 0 4px 0 0;
}
.ol-overviewmap .ol-overviewmap-map,
.ol-overviewmap button {
display: block;
}
.ol-overviewmap .ol-overviewmap-map {
border: 1px solid var(--ol-subtle-foreground-color);
height: 150px;
width: 150px;
}
.ol-overviewmap:not(.ol-collapsed) button {
bottom: 0;
left: 0;
position: absolute;
}
.ol-overviewmap.ol-collapsed .ol-overviewmap-map,
.ol-overviewmap.ol-uncollapsible button {
display: none;
}
.ol-overviewmap:not(.ol-collapsed) {
background: var(--ol-subtle-background-color);
}
.ol-overviewmap-box {
border: 1.5px dotted var(--ol-subtle-foreground-color);
}
.ol-overviewmap .ol-overviewmap-box:hover {
cursor: move;
}
.ol-overviewmap .ol-viewport:hover {
cursor: pointer;
}
html,
body {
margin: 0;
height: 100%;
}
#map {
position: absolute;
top: 0;
bottom: 0;
width: 100%;
}

View File

@ -0,0 +1,4 @@
export const getCookieValue = (name: string): string | null => {
const match = document.cookie.match('(^|;)\\s*' + name + '\\s*=\\s*([^;]+)');
return match ? match[2] : null;
};

View File

@ -0,0 +1,21 @@
import {
pinRed,
pinBlue,
pinGray,
pinGreen,
pinBlack,
pinYellow,
} from '../assets/icons'
export function getPinImageFromType(PinType: string) {
const pinMap: Record<string, string> = {
'Not Interested': pinRed,
"Not Home": pinGray,
"No Knock": pinBlack,
"Pitched": pinBlue,
"Sale": pinGreen,
"Go Back": pinYellow
}
return pinMap[PinType];
}

View File

@ -0,0 +1,19 @@
import type { Router } from "vue-router";
import { useAuthStore } from "../stores/authStore";
export async function useAuthCheck(router: Router) {
const authStore = useAuthStore();
const checkAuthStatus = async () => {
const isAuth = await authStore.checkAuth();
if (isAuth) {
console.log('Is Authorized/Logged In');
} else {
router.push('/login');
}
}
return { checkAuthStatus };
}

23
src/keys/certificate.pem Normal file
View File

@ -0,0 +1,23 @@
-----BEGIN CERTIFICATE-----
MIID6zCCAtOgAwIBAgIUbye9xxtKVgL1xQyblAtFaR3GNf4wDQYJKoZIhvcNAQEL
BQAwgZ0xCzAJBgNVBAYTAlVTMREwDwYDVQQIDAhBcmthbnNhczEUMBIGA1UEBwwL
TGl0dGxlIFJvY2sxFDASBgNVBAoMC0t5bGUgQXVzdGFkMRQwEgYDVQQLDAtLeWxl
IEF1c3RhZDEUMBIGA1UEAwwLS3lsZSBBdXN0YWQxIzAhBgkqhkiG9w0BCQEWFGt5
bGVhdXN0YWRAZ21haWwuY29tMB4XDTI1MDIxODE4MDkwMFoXDTI1MDMyMDE4MDkw
MFowgZ0xCzAJBgNVBAYTAlVTMREwDwYDVQQIDAhBcmthbnNhczEUMBIGA1UEBwwL
TGl0dGxlIFJvY2sxFDASBgNVBAoMC0t5bGUgQXVzdGFkMRQwEgYDVQQLDAtLeWxl
IEF1c3RhZDEUMBIGA1UEAwwLS3lsZSBBdXN0YWQxIzAhBgkqhkiG9w0BCQEWFGt5
bGVhdXN0YWRAZ21haWwuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC
AQEAohVRqMU9XjIgcD6waNNAR57qpET2sy2j4NejW8aGO6eyXo4iH5t0jH8+kcIH
W8hyUjBFrxk2pfq9ghR5XOQt6y7RkqqmBNzG3ewdLBA/dS47LVciAhpHirlAax+R
lypHi14gIUDzELWz4rMbjpZZVcfq1T7kVcQh7PlQCE0FW528H1v2x1cN+uy1Lvi3
vhXaXcvmztYnT35wRVIgx8yR5VIJMt4MhFQtTpJVc4W6fc1/05HyLUy6mfGrO9Au
G4zSg77BS02kOkWKFbf/LjAavJAceFXNk18c5o+JsrE3YiGdChatJoV/O+I28BlI
NDClKFtppAsElCWDl2XgnBhhXQIDAQABoyEwHzAdBgNVHQ4EFgQU5YGOukn+3KvO
pvTsxe19axjZF7QwDQYJKoZIhvcNAQELBQADggEBABXAjywFDNJVKSZvFTvgQevQ
9CM9+PalLVHea9QmUbOlubXQLi7V8FfVHKHEbMaMzNPes+/QRV4dfEISkOwjAFWD
gPccA3sA00R5OCcJe3VsxJ6KEKodnOngWFeI+wHf+PvabiUM4/D5Kf96/CU7Ab13
whN1lvExr+QXr7RjB/zJCoi0MBpA4TC1Ne1CEAu3WraHwMZ7NayeFSkXUQ+dqgfZ
Wb19jAlX6fUKMLmPbhv4pe2+JOCqdX2uWFRIEg2rmUwImNA8UQTADTjNNN5WvIAd
yyUfMeHhj/ZiKbm94YshGMVSWZ+8ey2KhtjQ33vnXQcIrnuSAQIZIyTpGcEuZAE=
-----END CERTIFICATE-----

18
src/keys/csr.pem Normal file
View File

@ -0,0 +1,18 @@
-----BEGIN CERTIFICATE REQUEST-----
MIIC/DCCAeQCAQAwgZ0xCzAJBgNVBAYTAlVTMREwDwYDVQQIDAhBcmthbnNhczEU
MBIGA1UEBwwLTGl0dGxlIFJvY2sxFDASBgNVBAoMC0t5bGUgQXVzdGFkMRQwEgYD
VQQLDAtLeWxlIEF1c3RhZDEUMBIGA1UEAwwLS3lsZSBBdXN0YWQxIzAhBgkqhkiG
9w0BCQEWFGt5bGVhdXN0YWRAZ21haWwuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOC
AQ8AMIIBCgKCAQEAohVRqMU9XjIgcD6waNNAR57qpET2sy2j4NejW8aGO6eyXo4i
H5t0jH8+kcIHW8hyUjBFrxk2pfq9ghR5XOQt6y7RkqqmBNzG3ewdLBA/dS47LVci
AhpHirlAax+RlypHi14gIUDzELWz4rMbjpZZVcfq1T7kVcQh7PlQCE0FW528H1v2
x1cN+uy1Lvi3vhXaXcvmztYnT35wRVIgx8yR5VIJMt4MhFQtTpJVc4W6fc1/05Hy
LUy6mfGrO9AuG4zSg77BS02kOkWKFbf/LjAavJAceFXNk18c5o+JsrE3YiGdChat
JoV/O+I28BlINDClKFtppAsElCWDl2XgnBhhXQIDAQABoBkwFwYJKoZIhvcNAQkH
MQoMCGF1c3RhZDEyMA0GCSqGSIb3DQEBCwUAA4IBAQA/gdfr5Fj8lLOZ439v7S21
hQYfw2AmmRWhkJWsZIcusQ+tB3S7jKTeGmjy3046+8StQcsk4x/AuFP05XqTn1qz
gMVm5m+yAiFANTMnDnbuoCIjuZe+XWfdlFDzQnMuVGmrEESzgIcUehsS5R3k8uFt
/pYNWRt5qcjeqxMjNp0E12kgWoj2kmPfxPowKB06jI2NtiKJcfO3q8iBc4D+JRtB
ynx7cirj66I8n9TGAXJ9USqVLGZq+1GFt+FE4m/iAUaGadC769oRs/f8/3pXDJGJ
KKnFfopPJ61Pl0aLx9y2O6s4BIynKC9jxX0JQzBVJXYw3p1vZUioltuj+ihWCYhx
-----END CERTIFICATE REQUEST-----

28
src/keys/private-key.pem Normal file
View File

@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCiFVGoxT1eMiBw
PrBo00BHnuqkRPazLaPg16NbxoY7p7JejiIfm3SMfz6RwgdbyHJSMEWvGTal+r2C
FHlc5C3rLtGSqqYE3Mbd7B0sED91LjstVyICGkeKuUBrH5GXKkeLXiAhQPMQtbPi
sxuOlllVx+rVPuRVxCHs+VAITQVbnbwfW/bHVw367LUu+Le+Fdpdy+bO1idPfnBF
UiDHzJHlUgky3gyEVC1OklVzhbp9zX/TkfItTLqZ8as70C4bjNKDvsFLTaQ6RYoV
t/8uMBq8kBx4Vc2TXxzmj4mysTdiIZ0KFq0mhX874jbwGUg0MKUoW2mkCwSUJYOX
ZeCcGGFdAgMBAAECggEAQyf2J6lhne/DBP7UdPpifcpYiiuQ0/irF/QA2XvODlWT
UB4wAUyV48itEhOEnQDWbTtXBA+8VtUnRAhbqKNaa0GAz/Yox4b6VuUHyUQKJvE6
z7R0gM6GqfHLwRbZafq4ngQn599TVq8Sk3GGj3O4HBSzNhvS275iGB/Mi0PuXmC5
sNjdUnjbjq0Q2j3L9BJnrufL/Ojc57Fba03iLhCLPelGKvgkskIGNL1E9MdFP3je
f2GWCXNnnouPXwtTD9JZ5xLXhfJ0Ho85aK2wP2yJ9HimAOCOrD4JBX1QEsLzC3VN
QhfkbaPW/xMkWunwldq5+L31/PVx6qevtxxXRCy4eQKBgQDTZ3bd+osF9XSXw8j1
rrYaC1YaVOCPJ7q3nYZuhqchyAtGDxtEYZlcO3Q0W1tRAgZdGjz6d9XXSU0GLTPz
F8vgq+J8BkuKz9itHqoq7JRGktLYDmSEAT1OsWImOPirtbyN7s3+20Fh8zapxAok
Lg+YuwkU8bCnBtZuez7IkZ/MGwKBgQDERlx4C4In0OT4wF6EhGf+Tm4UFSZ4JwbW
C3jU5HI5R0ze5H99bT1UW5WBe5tkuZW3Q7rZUq+myiLmwzNuXbMWrFqdd7+TpVX0
QJ4VTAdBAX8FYnhyf5crJorXE8w2sC5ybHu8g/Fjwb1MvhxE0MGCgTIjRJnX52KO
fl67ka7v5wKBgHtBPZqacufcfkflzIsBfAafSWo8xlhwr3pSi4hxVYxql7gXIqyx
wrp1p4DHKuyGI7OwkpDtwW2tvQy22i0HWMT7MidhfDXIjOoSkOBJ4wFqpSd27L/S
AS6aWUEzwjhK005lRxEqQGqdZOeB9u+1Mz62/cqKR5Z6dBciIC7MSMC1AoGAWyJI
gTzmvFIUXyQDPwizuLL2DcEcUaHdXLmK617/YnUmzj/OqpB0b5zuuGaKaqR5TdTX
B12LTaiBuCBe2xoKs7ZnhMI7Y/TbCkSOoljg7WAieH+WaaOwr0qAIQmCQspfZqXU
dl3VUXp9yQpk2dcMYupWO2X5APThIESw++rTDXUCgYATmv4xVvJYgfVsjB138+7C
I84X53nC73nrKaTYtOjeFQo5COkXD4C4IAyJXU480p1UTimA6Swhc+jxr5ga0GMz
7QXYFpPcBLYjF0oO/R4AVKT2hHCp1O6MHlbnSn1BCP1+eo5QUZzxA7f5DewQ8K38
YPXqV+ZO2PvpZmr0VHP6pg==
-----END PRIVATE KEY-----

36
src/main.ts Normal file
View File

@ -0,0 +1,36 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia';
import './style.css'
import "primeicons/primeicons.css";
import App from './App.vue'
import PrimeVue from 'primevue/config';
import Aura from '@primevue/themes/aura';
import router from './router/router';
import OpenLayersMap from 'vue3-openlayers';
import { MotionPlugin } from '@vueuse/motion';
import { ConfirmationService, ToastService } from 'primevue';
import { useAuthCheck } from './composables/useAuthCheck';
const pinia = createPinia();
const app = createApp(App);
app.use(PrimeVue, {
theme: {
preset: Aura
}
}).use(router).use(OpenLayersMap).use(pinia).use(MotionPlugin).use(ToastService).use(ConfirmationService);
const initAuthCheck = async () => {
const { checkAuthStatus } = await useAuthCheck(router);
checkAuthStatus();
}
initAuthCheck();
app.mount('#app')

84
src/router/router.ts Normal file
View File

@ -0,0 +1,84 @@
import { createRouter, createWebHistory } from "vue-router";
import { useAuthStore } from "../stores/authStore";
const routes = [
{
path: '/', component: () => import('../views/HomeView.vue')
},
{
path: '/pin-history/:id',
component: () => import('../views/PinHistoryView.vue'),
meta: { requiresAuth: true }
},
{
path: '/pin/:id',
component: () => import('../views/ViewPinView.vue'),
meta: { requiresAuth: true }
},
{
path: '/menu',
component: () => import('../views/MenuView.vue'),
meta: { requiresAuth: true }
},
{
path: '/login', component: () => import('../views/LoginView.vue')
},
{
path: '/office-select',
component: () => import('../views/OfficeSelectView.vue'),
meta: { requiresAuth: true }
},
{
path: '/users',
component: () => import('../views/AllUsersView.vue'),
meta: { requiresAuth: true, requiresManager: true }
},
{
path: '/users/:id',
component: () => import('../views/ViewUserView.vue'),
meta: { requiresAuth: true, requiresManager: true }
},
{
path: '/users/add-user',
component: () => import('../views/AddUserView.vue'),
meta: { requiresAuth: true, requiresManager: true }
},
{
path: '/teams',
component: () => import('../views/AllOfficesView.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: '/teams/:id',
component: () => import('../views/ViewOfficeView.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: '/teams/add-team',
component: () => import('../views/AddOfficeView.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
router.beforeEach(async (to, from, next) => {
const authStore = useAuthStore()
if (to.meta.requiresManager && authStore.user.role < 1) {
next('/');
} else if (to.meta.requiresAdmin && authStore.user.role < 2) {
next('/')
} else {
next();
}
})
export default router;

129
src/stores/areaStore.ts Normal file
View File

@ -0,0 +1,129 @@
import type { Area } from "../types/area";
import { defineStore } from "pinia";
import { ref } from "vue";
import { useAuthStore } from "./authStore";
import { useOfficeStore } from "./officeStore";
import { useAxios } from "@vueuse/integrations/useAxios";
import type { Coordinate } from "ol/coordinate";
export const useAreaStore = defineStore('areaStore', () => {
const authStore = useAuthStore();
const officeStore = useOfficeStore();
const userAreas = ref<Area[]>([]);
const selectedArea = ref<Area>();
const fetchUserAreas = async () => {
const queryString = authStore.user.role > 0 ? `?office=${officeStore.activeOffice?._id}` : `?owners=${authStore.user._id}`;
const { data, error } = await useAxios(`https://${authStore.company}.justcanvas.app/api/v1/areas${queryString}`, {
method: 'GET',
withCredentials: true,
});
if (error.value) {
console.error('Error Fetching Areas:', error.value);
throw error.value
}
userAreas.value = data.value.data.areas;
console.log(`Retreived ${userAreas.value.length} areas for current user!`);
return data.value.data.areas;
}
const saveNewArea = async (newArea: Area) => {
const { data, error } = await useAxios(`https://${authStore.company}.justcanvas.app/api/v1/areas`, {
method: 'POST',
withCredentials: true,
data: newArea
})
if (error.value) {
console.error('Error saving area to server:', error.value);
throw error.value
}
console.log('Response Data:', data)
const savedArea = data.value.data.area
userAreas.value.push(savedArea);
}
const findAreaFromCoords = async (coords: Array<Array<Array<number>>>) => {
const matchingArea = userAreas.value.find(area =>
area.geometry.coordinates[0].length === coords[0].length &&
area.geometry.coordinates[0].every((point, index) =>
point[0] === coords[0][index][0] && point[1] === coords[0][index][1])
)
return matchingArea
}
const deleteArea = async (areaId: string | undefined) => {
if (!areaId) {
return false
}
const { data, error } = await useAxios(`https://${authStore.company}.justcanvas.app/api/v1/areas/${areaId}`, {
method: 'DELETE',
withCredentials: true
})
if (error.value ) {
console.log('Error deleting Area!', error.value)
return false
} else if (!error.value) {
console.log('Delete success!')
const areaIndex = userAreas.value.findIndex(area => area._id === areaId);
if (areaIndex !== -1) {
userAreas.value.splice(areaIndex, 1)
}
return true
}
}
const fetchSingleArea = async (areaId: string | undefined) => {
if (!areaId) {
return
}
const { data, error } = await useAxios(`https://${authStore.company}.justcanvas.app/api/v1/areas/${areaId}`, {
method: 'GET',
withCredentials: true
})
if (error.value) {
console.error(error.value)
throw error.value
} else if (!error.value) {
console.log('Fetched single area')
return data.value.data.area
}
}
const updateArea = async (newArea: Area | undefined) => {
if (!newArea?._id) {
return false
}
const { data, error } = await useAxios(`https://${authStore.company}.justcanvas.app/api/v1/areas/${newArea._id}`, {
method: 'PATCH',
withCredentials: true,
data: newArea
})
if (error.value) {
console.log('Error updating Area:', error.value)
return false
} else if (!error.value) {
console.log('Updating Success!')
const areaIndex = userAreas.value.findIndex(area => area._id === newArea._id)
if (areaIndex !== -1) {
userAreas.value[areaIndex] = data.value.data.area
}
return true
}
}
return { userAreas, selectedArea, fetchUserAreas, saveNewArea, fetchSingleArea, updateArea, findAreaFromCoords, deleteArea };
})

109
src/stores/authStore.ts Normal file
View File

@ -0,0 +1,109 @@
import { defineStore } from "pinia";
import { ref, computed } from "vue";
import { useAxios } from "@vueuse/integrations/useAxios";
import type { User } from "../types/user";
import type { Office } from "../types/office";
import type { Credentials } from "../types/credentials";
import { usePinStore } from "./pinStore";
import { useOfficeStore } from "./officeStore";
import { getCookieValue } from "../composables/getCookieValue";
import { useAreaStore } from "./areaStore";
export const useAuthStore = defineStore('authStore', () => {
const pinStore = usePinStore();
const officeStore = useOfficeStore();
const areaStore = useAreaStore();
const defaultUser = {
_id: '',
username: '',
firstName: '',
lastName: '',
role: 0,
offices: <Office[]>[],
}
const user = ref<User>(defaultUser)
const isAuthenticated = computed(() => user.value._id !== '' && company.value !== '');
const company = ref<string | null>('');
const login = async (credentials: Credentials) => {
company.value = credentials.company;
const { data, error } = await useAxios(
`https://${company.value}.justcanvas.app/api/v1/auth/login`, {
method: 'POST',
data: credentials,
withCredentials: true,
}
);
if (error.value) {
console.error('Login Failed:', error.value);
user.value = defaultUser;
throw error.value;
}
user.value = data.value.user;
document.cookie = `company=${credentials.company}; path=/; max-age=${30 * 24 * 60 * 60}`;
};
const checkAuth = async () => {
const companyCookieFound = document.cookie.split(';').some((cookie) => cookie.trim().startsWith('company='));
if (!companyCookieFound) {
console.log('Couldnt find company');
return;
}
console.log('Found companyCookie = ', companyCookieFound)
const companyCookie = getCookieValue('company');
console.log('Company value:', companyCookie);
const { data, error } = await useAxios(
`https://${companyCookie}.justcanvas.app/api/v1/auth/me`, {
method: 'GET',
withCredentials: true,
}
);
// console.log('Axios Results:', data.value, error.value)
if (error.value) {
user.value = defaultUser;
console.warn('User not authenticated!');
return false;
}
//Update state from logged in user
user.value = data.value.user;
company.value = companyCookie;
officeStore.checkForActiveOffice();
if (officeStore.activeOffice){
pinStore.userPins = await pinStore.fetchUserPins(user.value._id as string);
areaStore.userAreas = await areaStore.fetchUserAreas();
}
return true;
};
const logout = async () => {
user.value = defaultUser;
};
return { user, isAuthenticated, login, checkAuth, logout, company };
});

142
src/stores/officeStore.ts Normal file
View File

@ -0,0 +1,142 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import { useAuthStore } from "./authStore";
import type { Office } from "../types/office";
import { getCookieValue } from "../composables/getCookieValue";
import { useRouter } from "vue-router";
import { useAxios } from "@vueuse/integrations/useAxios";
export const useOfficeStore = defineStore('officeStore', () => {
const authStore = useAuthStore();
const router = useRouter();
const activeOffice = ref<Office>();
const allOffices = ref<Office[]>([]);
const filteredOffices = ref<Office[]>([]);
const setActiveOffice = (officeId: string) => {
const activeIndex = authStore.user.offices.findIndex((office) => office._id === officeId);
activeOffice.value = authStore.user.offices[activeIndex];
document.cookie = `office=${officeId}; path=/; max-age=${30 * 24 * 60 * 60}`;
}
const checkForActiveOffice = () => {
const officeCookieFound = document.cookie.split(';').some((cookie) => cookie.trim().startsWith('office='));
if (officeCookieFound) {
const cookieValue = getCookieValue('office');
const foundIndex = authStore.user.offices.findIndex((office) => office._id === cookieValue);
activeOffice.value = authStore.user.offices[foundIndex];
console.log('Active Office', activeOffice.value)
} else if (!officeCookieFound) {
router.push('/office-select');
}
}
const getAllOffices = async () => {
const { data, error } = await useAxios(`https://${authStore.company}.justcanvas.app/api/v1/offices`, {
method: 'GET',
withCredentials: true
})
if (error.value) {
console.log('Error Fetching Offices', error.value)
throw error.value
}
console.log('Got Offices:', data.value.results)
allOffices.value = data.value.data.offices
}
const saveNewOffice = async (newOffice: Office) => {
const { data, error } = await useAxios(`https://${authStore.company}.justcanvas.app/api/v1/offices`, {
method: 'POST',
withCredentials: true,
data: newOffice
})
if (error.value) {
console.error('Error Saving Office', error.value)
throw error.value
}
const savedOffice = data.value.data.office
allOffices.value.push(savedOffice);
}
const deleteOffice = async (officeId: string | undefined) => {
if (!officeId) {
return false
}
const { data, error } = await useAxios(`https://${authStore.company}.justcanvas.app/api/v1/offices/${officeId}`, {
method: 'DELETE',
withCredentials: true
})
if (error.value) {
console.log('Error Deleting Office:', error.value)
return false
} else if (!error.value) {
console.log('Delete Success!')
const officeIndex = allOffices.value.findIndex(office => office._id === officeId);
if (officeIndex !== -1) {
allOffices.value.splice(officeIndex, 1)
filteredOffices.value = allOffices.value
}
return true;
}
}
const getSingleOffice = async (officeId: string | undefined) => {
if (!officeId) {
return
}
const { data, error } = await useAxios(`https://${authStore.company}.justcanvas.app/api/v1/offices/${officeId}`, {
method: 'GET',
withCredentials: true
})
if (error.value) {
console.error('Error getting office:', error.value)
throw error.value
}
return data.value.data.office
}
const updateOffice = async (newOffice: Office | undefined) => {
if (!newOffice?._id) {
return false
}
const { data, error } = await useAxios(`https://${authStore.company}.justcanvas.app/api/v1/offices/${newOffice._id}`, {
method: 'PATCH',
withCredentials: true,
data: newOffice
})
if (error.value) {
console.log('Error Updating Office', error.value)
return false
} else if (!error.value) {
console.log('Updating Success!')
const officeIndex = allOffices.value.findIndex(office => office._id === newOffice._id)
if (officeIndex !== -1) {
allOffices.value[officeIndex] = data.value.data.office
}
return true;
}
}
return { activeOffice, getSingleOffice, updateOffice, setActiveOffice, deleteOffice, saveNewOffice, filteredOffices, checkForActiveOffice, allOffices, getAllOffices };
})

126
src/stores/pinStore.ts Normal file
View File

@ -0,0 +1,126 @@
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import type { Pin } from '../types/pin';
import { useAuthStore } from './authStore';
import { useOfficeStore } from './officeStore';
import { useSettingsStore } from './settingsStore';
import { useAxios } from '@vueuse/integrations/useAxios';
import type { Coordinate } from 'ol/coordinate';
export const usePinStore = defineStore('pinStore', () => {
const authStore = useAuthStore();
const officeStore = useOfficeStore();
const settingsStore = useSettingsStore();
const userPins = ref<Pin[]>([]);
const selectedPin = ref<Pin>();
const fetchUserPins = async (userId: string | undefined) => {
const timeFrame = (parseInt(settingsStore.pinAgeFilter ? settingsStore.pinAgeFilter : '2') * 24 * 60 * 60 * 1000)
const timeFilter = Date.now() - timeFrame
if (!userId) {
console.log('ERROR FETCHING PINS')
throw new Error('UserID for request was undefined')
}
const { data, error } = await useAxios(`https://${authStore.company}.justcanvas.app/api/v1/pins?owner=${userId}&office=${officeStore.activeOffice?._id}&createdAt[gte]=${timeFilter}`, {
method: 'GET',
withCredentials: true,
})
if (error.value) {
console.error('Error Fetching Pins:', error.value);
throw error.value;
}
// console.log(`https://${authStore.company}.justcanvas.app/api/v1/pins?owner=${userId}`)
// userPins.value = data.value.data.pins;
console.log(`Retrieved ${data.value.data.pins.length} pins!`);
return data.value.data.pins;
};
const saveNewPin = async (newPin: Pin) => {
const { data, error } = await useAxios(`https://${authStore.company}.justcanvas.app/api/v1/pins`, {
method: 'POST',
withCredentials: true,
data: newPin
})
if (error.value) {
console.error('Error saving pin to server:', error.value);
throw error.value;
}
const savedPin = data.value.data.pin
userPins.value.push(savedPin);
}
const findPinFromCoords = async (coords: Coordinate) => {
const matchingPin = userPins.value.find(pin => pin.coordinates.coordinates[0] === coords[0] && pin.coordinates.coordinates[1] === coords[1])
return matchingPin
}
const setUserPins = (pins: Pin[]) => {
userPins.value = pins;
}
const deletePin = async (pinId: string | undefined) => {
if (!pinId) {
return false
}
const { data, error } = await useAxios(`https://${authStore.company}.justcanvas.app/api/v1/pins/${pinId}`, {
method: 'DELETE',
withCredentials: true
})
if (error.value) {
console.log('Error Deleting Pin:', error.value)
return false;
} else if (!error.value) {
console.log('Delete Success!', data.value)
const pinIndex = userPins.value.findIndex(pin => pin._id === pinId);
if (pinIndex !== -1) {
userPins.value.splice(pinIndex, 1);
}
return true;
}
}
const updatePin = async (newPin: Pin | undefined) => {
if (!newPin?._id) {
return false
}
const { data, error } = await useAxios(`https://${authStore.company}.justcanvas.app/api/v1/pins/${newPin._id}`, {
method: 'PATCH',
withCredentials: true,
data: newPin
})
if (error.value) {
console.log('Error updating Pin', error.value)
return false
} else if (!error.value) {
console.log('Update Success!', data.value)
const pinIndex = userPins.value.findIndex(pin => pin._id === newPin._id)
if (pinIndex !== -1) {
userPins.value[pinIndex] = data.value.data.pin
}
return true
}
}
return { userPins, updatePin, fetchUserPins, saveNewPin, findPinFromCoords, setUserPins, selectedPin, deletePin };
})

View File

@ -0,0 +1,35 @@
import { defineStore } from "pinia";
import { ref, watch } from "vue";
import Cookies from "js-cookie";
export const useSettingsStore = defineStore('settingsStore', () => {
const mapOpacity = ref(0);
const areaFillColor = ref<string | undefined>("rgba(0, 106, 255, 0.15)")
const areaLineColor = ref<string | undefined>("001aff")
const pinAgeFilter = ref<string | undefined>('2')
const cachedCoords = <Array<number> | null>null;
watch(areaFillColor, (newColor) => {
Cookies.set('areaFillColor', (newColor || "rgba(0, 106, 255, 0.15)"));
})
watch(areaLineColor, (newColor) => {
Cookies.set('areaLineColor', newColor || "001aff")
})
watch(pinAgeFilter, (newPinAge) => {
Cookies.set('pinAgeFilter', (`${newPinAge}`))
})
if (Cookies.get('areaFillColor')) {
areaFillColor.value = Cookies.get('areaFillColor')
}
if (Cookies.get('areaLineColor')) {
areaLineColor.value = Cookies.get('areaLineColor');
}
if (Cookies.get('pinAgeFilter')) {
pinAgeFilter.value = Cookies.get('pinAgeFilter')
}
return { mapOpacity, areaFillColor, areaLineColor, cachedCoords, pinAgeFilter }
})

136
src/stores/usersStore.ts Normal file
View File

@ -0,0 +1,136 @@
import { defineStore } from "pinia";
import { useAuthStore } from "./authStore";
import { useOfficeStore } from "./officeStore";
import { ref } from "vue";
import type { User } from "../types/user";
import { useAxios } from "@vueuse/integrations/useAxios";
export const useUsersStore = defineStore('usersStore', () => {
const authStore = useAuthStore()
const officeStore = useOfficeStore()
const allUsers = ref<User[]>([]);
const filteredUsers = ref<User[]>([]);
const activeViewedUser = <User>({});
const fetchUserFromId = async (userId: string) => {
const { data, error } = await useAxios(`https://${authStore.company}.justcanvas.app/api/v1/users/${userId}`, {
method: 'GET',
withCredentials: true,
})
if (error.value) {
console.error('Error Fetching Pins:', error.value)
throw error.value
}
return data.value.data.user
}
const fetchAllUsersBasedOnRole = async () => {
let queryString = '';
if (authStore.user.role === 1) {
queryString = `?offices=${officeStore.activeOffice?._id}&role[lt]=${authStore.user.role}`
} else if (authStore.user.role === 2 ){
queryString = `?role[lte]=${authStore.user.role}`
}
const { data, error } = await useAxios(`https://${authStore.company}.justcanvas.app/api/v1/users${queryString}`, {
method: 'GET',
withCredentials: true
})
if (error.value) {
console.error('Error Fetching Users:', error.value)
throw error.value
}
allUsers.value = data.value.data.users
}
const fetchAllUsersInActiveOffice = async () => {
const { data, error } = await useAxios(`https://${authStore.company}.justcanvas.app/api/v1/users?offices=${officeStore.activeOffice?._id}`, {
method: 'GET',
withCredentials: true
})
if (error.value) {
throw error.value
}
allUsers.value = data.value.data.users
}
const saveNewUser = async (newUser: User) => {
const { data, error } = await useAxios(`https://${authStore.company}.justcanvas.app/api/v1/users`, {
method: 'POST',
withCredentials: true,
data: newUser
})
if (error.value) {
console.error('Error Saving User', error.value)
throw error.value
}
const savedUser = data.value.data.user
allUsers.value.push(savedUser);
}
const deleteUser = async (userId: string | undefined) => {
if (!userId) {
return false
}
const { data, error} = await useAxios(`https://${authStore.company}.justcanvas.app/api/v1/users/${userId}`, {
method: 'DELETE',
withCredentials: true
})
if (error.value) {
console.log('Error deleting user:', error.value)
return false
} else if (!error.value) {
console.log('Delete Success!')
const userIndex = allUsers.value.findIndex(user => user._id === userId)
if (userIndex !== -1) {
allUsers.value.splice(userIndex, 1)
filteredUsers.value.splice(userIndex, 1)
}
return true;
}
}
const updateUser = async (newUser: User | undefined) => {
if (!newUser?._id) {
return false
}
const { data, error } = await useAxios(`https://${authStore.company}.justcanvas.app/api/v1/users/${newUser._id}`, {
method: 'PATCH',
withCredentials: true,
data: newUser
})
if (error.value) {
console.log('Error updating user', error.value)
return false
} else if (!error.value) {
console.log('Update Success!', data.value)
const userIndex = allUsers.value.findIndex(user => user._id === newUser._id)
if (userIndex !== -1) {
allUsers.value[userIndex] = data.value.data.user
filteredUsers.value = [...allUsers.value].sort((a, b) => {
return b.lastName.localeCompare(a.lastName)
})
}
return true
}
}
return { activeViewedUser, updateUser, deleteUser, allUsers, fetchUserFromId, fetchAllUsersBasedOnRole, saveNewUser, filteredUsers, fetchAllUsersInActiveOffice }
})

147
src/style.css Normal file
View File

@ -0,0 +1,147 @@
.pinLogo {
width: 12px;
}
html, body {
height: 100%;
margin: 0;
}
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.page-container {
display: flex;
flex-direction: column;
height: 100vh;
}
.scroll-container {
overflow-y: auto;
flex-grow: 1;
}
.search-bar {
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
margin-top: 5px;
margin-bottom: 2px;
box-shadow: 1px 2px 1px #a0a0a0;
}
.shadow-button {
box-shadow: 8px 8px 10px #3131319c;
}
.option-container {
width: 100%;
display: flex;
justify-content: center;
padding: 10px;
}
.option-container Button {
margin-left: 5%;
margin-right: 5%;
}
.pin-entry {
width: 100%;
height: auto;
padding-top: 2.5px;
padding-bottom: 2.5px;
}
.pin-entry img {
width: 20px;
}
.pin-icon {
width: 20px;
margin-right: 50px;
margin-left: 10px
}
.entry-background {
display: flex;
padding: 5px;
background-color: #3d3d3d;
box-shadow: 0px 5px 5px #000000e8;
border-radius: 15px;
margin: 10px;
}
.info-container {
display: flex;
text-align: left;
width: 100%;
align-items: center;
margin-left: 2.7%;
margin-right: 2.7%;
justify-content: space-between;
}
.entry-container {
display: flex;
justify-content: space-between;
align-items: center;
min-width: 210px;
}
.form-group {
padding: 1%;
margin-right: 2%;
margin-left: 2%;
display: flex;
flex-direction: column;
}
.form-buttons {
display: flex;
width: 100%;
justify-content: center;
padding: 10%;
}
.form-group h3 {
margin: 0;
margin-top: 3px;
margin-bottom: 10px;
}
.more-space {
margin-top: 5%;
margin-bottom: 5%;
}
.button-container {
margin-top: 4px;
justify-content: space-between;
align-items: center;
align-content: center;
display: flex;
min-width: 100px;
max-width: 40%;
max-height: 40px;
}
.text-container {
display: flex;
flex-direction: column;
min-width: 160px;
}
.text-container h3 {
margin-top: 0;
margin-bottom: 0;
padding-left: 15px;
}
.text-container p {
margin-top: 0;
margin-bottom: 0;
padding-left: 15px;
font-size: small;
color: gray;
}

13
src/types/area.ts Normal file
View File

@ -0,0 +1,13 @@
import type { User } from "./user";
export interface Area {
_id?: string;
type: "Polygon";
geometry: {
type: "Polygon";
coordinates: Array<Array<Array<number>>>;
};
owners?: string[] | User[];
office: string;
}

5
src/types/credentials.ts Normal file
View File

@ -0,0 +1,5 @@
export interface Credentials {
username: string;
password: string;
company: string;
}

7
src/types/office.ts Normal file
View File

@ -0,0 +1,7 @@
export interface Office {
_id?: string;
name: string;
createdAt?: Date;
city: string;
state: string;
}

21
src/types/pin.ts Normal file
View File

@ -0,0 +1,21 @@
import type { Coordinate } from "ol/coordinate";
export interface Pin {
_id?: string;
coordinates: {
type: string;
coordinates: Coordinate
},
owner: string;
office: string;
type: "Not Interesed" | "Not Home" | "No Knock" | "Sale" | "Go Back" | "Pitched" | string;
address?: string;
city?: string;
state?: string;
zip?: string;
name?: string;
phone?: string;
email?: string;
notes?: string;
createdAt?: Date;
}

13
src/types/user.ts Normal file
View File

@ -0,0 +1,13 @@
import type { Office } from "./office";
export interface User {
_id?: string;
username: string;
password?: string;
firstName: string;
lastName: string;
createdAt?: Date;
role: number;
offices: Office[];
supervisor?: User;
}

View File

@ -0,0 +1,13 @@
<script setup lang="ts">
import HeaderBar from '../components/HeaderBar.vue'
import BottomBar from '../components/BottomBar.vue'
import AddTeamForm from '../components/AddTeamForm.vue'
</script>
<template>
<HeaderBar title="Add Team" />
<div class="scroll-container">
<AddTeamForm></AddTeamForm>
</div>
</template>

52
src/views/AddUserView.vue Normal file
View File

@ -0,0 +1,52 @@
<script setup lang="ts">
import HeaderBar from '../components/HeaderBar.vue'
import BottomBar from '../components/BottomBar.vue'
import AddUserForm from '../components/AddUserForm.vue'
import { ref, onMounted } from 'vue'
import { ConfirmDialog, Toast } from 'primevue'
import { useAuthStore } from '../stores/authStore'
import { useOfficeStore } from '../stores/officeStore'
import { useUsersStore } from '../stores/usersStore'
import type { Office } from '../types/office'
const authStore = useAuthStore()
const officeStore = useOfficeStore()
const usersStore = useUsersStore()
const availableRoles = ref([{ name: 'User', value: 0 }])
const availableOffices = ref<Office[]>(authStore.user.offices)
onMounted(async () => {
if (authStore.user.role === 2) {
availableRoles.value.push({ name: 'Team Manager', value: 1 }, { name: 'Admin', value: 2 })
}
if (authStore.user.role === 3) {
availableRoles.value.push(
{ name: 'Team Manager', value: 1 },
{ name: 'Admin', value: 2 },
{ name: 'System Admin', value: 3 }
)
}
if (authStore.user.role >= 2) {
await officeStore.getAllOffices()
availableOffices.value = [...officeStore.allOffices]
}
})
</script>
<template>
<ConfirmDialog />
<Toast style="max-width: 300px" />
<div class="page-container">
<HeaderBar title="Add User" />
<div class="scroll-container">
<AddUserForm
:possible-offices="availableOffices"
:possible-roles="availableRoles"
:possible-supervisors="usersStore.allUsers"
></AddUserForm>
</div>
</div>
</template>

View File

@ -0,0 +1,55 @@
<script setup lang="ts">
import HeaderBar from '../components/HeaderBar.vue'
import BottomBar from '../components/BottomBar.vue'
import AddButton from '../components/AddButton.vue'
import { ConfirmDialog, InputText, InputIcon, IconField, Toast } from 'primevue'
import { onMounted, ref } from 'vue'
import { useDebounceFn } from '@vueuse/core'
import AllOfficesList from '../components/AllOfficesList.vue'
import type { Office } from '../types/office'
import { useOfficeStore } from '../stores/officeStore'
const officeStore = useOfficeStore()
const officeFilter = ref('')
const debounceSearch = useDebounceFn(() => {
console.log('Search Changed')
if (!officeFilter.value.trim()) {
officeStore.filteredOffices = [...officeStore.allOffices]
} else {
officeStore.filteredOffices = officeStore.allOffices.filter(
(office: Office) =>
office.name?.toLowerCase().includes(officeFilter.value.toLowerCase()) ||
office.city.toLowerCase().includes(officeFilter.value.toLowerCase()) ||
office.state.toLowerCase().includes(officeFilter.value.toLowerCase())
)
}
}, 500)
onMounted(async () => {
await officeStore.getAllOffices()
officeStore.filteredOffices = [...officeStore.allOffices]
})
</script>
<template>
<ConfirmDialog />
<Toast style="max-width: 300px" />
<div class="page-container">
<HeaderBar title="Teams" />
<AddButton mode="teams" />
<div class="search-bar">
<IconField>
<InputIcon class="pi pi-search" />
<InputText @value-change="debounceSearch" v-model="officeFilter" label="search" />
</IconField>
</div>
<div class="scroll-container">
<AllOfficesList :offices="officeStore.filteredOffices" />
</div>
</div>
<BottomBar />
</template>

View File

@ -0,0 +1,64 @@
<script setup lang="ts">
import HeaderBar from '../components/HeaderBar.vue'
import BottomBar from '../components/BottomBar.vue'
import AddButton from '../components/AddButton.vue'
import AllUsersList from '../components/AllUsersList.vue'
import { ConfirmDialog, InputText, InputIcon, IconField, Toast } from 'primevue'
import { onMounted, ref } from 'vue'
import { useDebounceFn } from '@vueuse/core'
import type { User } from '../types/user'
import { useUsersStore } from '../stores/usersStore'
const usersStore = useUsersStore()
const userFilter = ref('')
const debounceSearch = useDebounceFn(() => {
console.log('Search Changed')
if (!userFilter.value.trim()) {
usersStore.filteredUsers = [...usersStore.allUsers].sort((a: User, b: User) => {
return a.lastName.localeCompare(b.lastName)
})
} else {
usersStore.filteredUsers = usersStore.allUsers
.filter(
(user) =>
user.username?.toLowerCase().includes(userFilter.value.toLowerCase()) ||
`${user.firstName} ${user.lastName}`
.toLowerCase()
.includes(userFilter.value.toLowerCase())
)
.sort((a, b) => {
return a.lastName.localeCompare(b.lastName)
})
}
}, 500)
onMounted(async () => {
await usersStore.fetchAllUsersBasedOnRole()
usersStore.filteredUsers = [...usersStore.allUsers].sort((a, b) => {
return a.lastName.localeCompare(b.lastName)
})
})
</script>
<template>
<ConfirmDialog />
<Toast style="max-width: 300px" />
<div class="page-container">
<HeaderBar title="Users" />
<AddButton mode="users" />
<div class="search-bar">
<IconField>
<InputIcon class="pi pi-search" />
<InputText @value-change="debounceSearch" v-model="userFilter" label="search" />
</IconField>
</div>
<div class="scroll-container">
<AllUsersList />
</div>
</div>
<BottomBar />
</template>

278
src/views/HomeView.vue Normal file
View File

@ -0,0 +1,278 @@
<script setup lang="ts">
import BottomBar from '../components/BottomBar.vue'
import MapComponent from '../components/MapComponent.vue'
import SettingsButton from '../components/SettingsButton.vue'
import RadialMenu from '../components/RadialMenu.vue'
import ActiveOfficeLabel from '../components/ActiveOfficeLabel.vue'
import PinDialogue from '../components/PinDialogue.vue'
import AreaSelectToggle from '../components/AreaSelectToggle.vue'
import SettingsDrawer from '../components/SettingsDrawer.vue'
import PinSettingsDrawer from '../components/PinSettingsDrawer.vue'
import FeatureSelectDrawer from '../components/FeatureSelectDrawer.vue'
import EditAreaDrawer from '../components/EditAreaDrawer.vue'
import { ref } from 'vue'
import { onLongPress } from '@vueuse/core'
import type { ComponentPublicInstance } from 'vue'
import { Toast, ConfirmDialog } from 'primevue'
import { useAuthStore } from '../stores/authStore'
import { useOfficeStore } from '../stores/officeStore'
import { usePinStore } from '../stores/pinStore'
import { useAreaStore } from '../stores/areaStore'
import type { Pin } from '../types/pin'
import type { Area } from '../types/area'
import type { Point, Polygon } from 'ol/geom'
interface PinSettingsDrawerMethods {
toggleDrawerVisible: () => void
}
///////////////////////////////////////////////////////////////////////////////////////
///////////////////////////////////////////////////////////////////////////////////////
const authStore = useAuthStore()
const officeStore = useOfficeStore()
const pinStore = usePinStore()
const areaStore = useAreaStore()
const radialX = ref(0)
const radialY = ref(0)
const drawConfig = ref({
drawEnable: false,
drawType: 'Polygon',
})
const areaSelectEnabled = ref(false)
const areaSelectToggleVisible = ref(false)
const clickEvent = ref<PointerEvent | null>(null)
const changeCount = ref(0)
const htmlRefHook = ref<HTMLElement>()
const longPressedHook = ref(false)
const pinDialogueVisibile = ref(false)
const mapComponentRef = ref()
const settingsDrawerRef = ref<ComponentPublicInstance<PinSettingsDrawerMethods> | null>(null)
const featureSelectDrawerRef = ref<ComponentPublicInstance<PinSettingsDrawerMethods> | null>(null)
const pinSettingsDrawerRef = ref<ComponentPublicInstance<PinSettingsDrawerMethods> | null>(null)
const editAreaDrawerRef = ref<ComponentPublicInstance<PinSettingsDrawerMethods> | null>(null)
const newPin = ref<Pin>({
coordinates: {
type: 'Point',
coordinates: [0, 0],
},
owner: `${authStore.user._id}`,
office: `${officeStore.activeOffice?._id}`,
type: 'Not Interesed',
})
const selectedPin = ref<Pin>()
const selectedArea = ref<Area>()
const selectMode = ref<'Pin' | 'Area'>('Pin')
//////////////////////////////////////////////////////////////////////////////////////////
//////////////////////////////////////////////////////////////////////////////////////////
async function onLongPressCallback(event: PointerEvent) {
// If we are drawing area, exit immediately
if (drawConfig.value.drawEnable === true) return
// Check for features at long press
if (mapComponentRef.value) {
const feature = mapComponentRef.value?.getFeaturesAtClick(event)
if (feature) {
const featureType = feature.getGeometry().getType()
if (featureType === 'Point') {
const pinCoords = (feature.getGeometry() as Point).getCoordinates()
const foundPin = await pinStore.findPinFromCoords(pinCoords)
console.log('Found matching pin:', foundPin)
selectedPin.value = foundPin
selectMode.value = 'Pin'
toggleFeatureSelect()
return
}
if (featureType === 'Polygon' && areaSelectEnabled.value) {
const areaCoords = (feature.getGeometry() as Polygon).getCoordinates()
console.log('Area Coords:', areaCoords)
const foundArea = await areaStore.findAreaFromCoords(areaCoords)
console.log('Found matching area:', foundArea)
selectedArea.value = foundArea
selectMode.value = 'Area'
toggleFeatureSelect()
return
}
}
}
//Check if wanting to select area mode is active so as not to open radial
if (areaSelectEnabled.value) return
// Standard Pin Dropping and Radial Menu
clickEvent.value = event
longPressedHook.value = true
radialX.value = event.clientX
radialY.value = event.clientY
if (mapComponentRef.value) {
newPin.value.coordinates.coordinates = mapComponentRef.value.determineCoordsFromPixel(event)
}
}
interface pinInfo {
address?: string
city?: string
state?: string
zip?: string
name?: string
phone?: string
email?: string
notes?: string
}
const handleSavedPinClicked = async (pinInfo: pinInfo) => {
pinDialogueVisibile.value = false
newPin.value.address = pinInfo.address
newPin.value.city = pinInfo.city
newPin.value.state = pinInfo.state
newPin.value.zip = pinInfo.zip
newPin.value.name = pinInfo.name
newPin.value.email = pinInfo.email
newPin.value.phone = pinInfo.phone
newPin.value.notes = pinInfo.notes
await pinStore.saveNewPin(newPin.value)
}
onLongPress(htmlRefHook, onLongPressCallback, {
modifiers: {
prevent: true,
},
})
const hideRadial = () => {
longPressedHook.value = false
}
const enterAreaMode = () => {
if (!areaSelectToggleVisible.value) {
areaSelectToggleVisible.value = true
drawConfig.value.drawEnable = false
areaSelectEnabled.value = true
} else if (areaSelectToggleVisible.value) {
areaSelectToggleVisible.value = false
drawConfig.value.drawEnable = false
areaSelectEnabled.value = false
}
// drawConfig.value.drawType = 'Polygon'
// drawConfig.value.drawEnable = !drawConfig.value.drawEnable
}
const handleClickedPin = (pinType: string) => {
newPin.value.type = pinType
pinDialogueVisibile.value = true
}
const handleAreaToggle = () => {
drawConfig.value.drawEnable = !drawConfig.value.drawEnable
areaSelectEnabled.value = !areaSelectEnabled.value
}
const handleMapSettings = () => {
if (settingsDrawerRef.value) {
settingsDrawerRef.value.toggleDrawerVisible()
}
}
const handleEditArea = () => {
if (editAreaDrawerRef.value) {
editAreaDrawerRef.value.toggleDrawerVisible()
}
}
const handlePinFilterChanged = async () => {
pinStore.userPins = await pinStore.fetchUserPins(authStore.user._id as string)
}
const togglePinSettings = () => {
if (pinSettingsDrawerRef.value) {
pinSettingsDrawerRef.value.toggleDrawerVisible()
}
}
const toggleFeatureSelect = () => {
if (featureSelectDrawerRef.value) {
featureSelectDrawerRef.value.toggleDrawerVisible()
}
}
const updateMapComponent = () => {
changeCount.value += 1
}
</script>
<template>
<ConfirmDialog></ConfirmDialog>
<Toast style="max-width: 300px"> </Toast>
<div class="map-container" ref="htmlRefHook">
<MapComponent
ref="mapComponentRef"
:key="changeCount"
:area-select-enabled="areaSelectEnabled"
:drawEnable="drawConfig.drawEnable"
:drawType="drawConfig.drawType"
:clickEvent="clickEvent"
/>
</div>
<SettingsButton
@clicked-map="handleMapSettings"
@clicked-edit="enterAreaMode"
@clicked-pin="togglePinSettings"
:area-permissions="authStore.user?.role >= 1"
/>
<RadialMenu
class="radial-menu"
v-if="longPressedHook"
:visibility="longPressedHook"
@clicked-pin="handleClickedPin"
@menuHidden="hideRadial"
:style="{ left: radialX - 30 + 'px', top: radialY - 35 + 'px' }"
/>
<ActiveOfficeLabel />
<PinDialogue
class="pin-dialogue"
:dialogueVisible="pinDialogueVisibile"
@clicked-close="pinDialogueVisibile = false"
@clicked-save="handleSavedPinClicked"
:new-pin-coords="newPin.coordinates.coordinates"
/>
<SettingsDrawer ref="settingsDrawerRef" @settings-changed="updateMapComponent" />
<PinSettingsDrawer
@settings-changed="handlePinFilterChanged"
ref="pinSettingsDrawerRef"
></PinSettingsDrawer>
<FeatureSelectDrawer
@edit-area="handleEditArea"
:selected-area="selectedArea"
:mode="selectMode"
:selected-pin="selectedPin"
ref="featureSelectDrawerRef"
/>
<EditAreaDrawer ref="editAreaDrawerRef" />
<BottomBar />
<AreaSelectToggle v-if="areaSelectToggleVisible" @toggled-modes="handleAreaToggle" />
</template>
<style scoped>
.map-container {
height: calc(100vh);
padding-left: 1px;
padding-right: 1px;
padding-bottom: 1px;
padding-top: 1px;
}
.radial-menu {
position: absolute;
transform: translate(-50%, -50%);
}
</style>

133
src/views/LoginView.vue Normal file
View File

@ -0,0 +1,133 @@
<script setup lang="ts">
import { ref } from 'vue'
import { FloatLabel, InputText, Button } from 'primevue'
import BottomBar from '../components/BottomBar.vue'
import { useAuthStore } from '../stores/authStore'
import { useOfficeStore } from '../stores/officeStore'
import { useRouter } from 'vue-router'
import { useToast, Toast } from 'primevue'
const router = useRouter()
const authStore = useAuthStore()
const officeStore = useOfficeStore()
const toast = useToast()
const formData = ref({
username: '',
password: '',
company: '',
})
interface CustomError {
response?: {
data?: {
message?: string
}
}
}
const handleLogin = async () => {
try {
await authStore.login(formData.value)
console.log('Logged in successfully')
toast.add({
severity: 'secondary',
summary: 'Login Success!',
detail: 'Logged In Successfully!',
life: 2500,
})
if (authStore.user.offices.length < 2) {
officeStore.setActiveOffice(authStore.user.offices[0]._id || '')
}
router.push(authStore.user.offices.length > 1 ? '/office-select' : '/')
} catch (err) {
console.error('Login Error:', err)
const errorMessage = (err as CustomError)?.response?.data?.message ?? 'Company Invalid!'
toast.add({
severity: 'error',
summary: 'Login Failed!',
detail: `${errorMessage}`,
life: 5000,
})
}
}
</script>
<template>
<Toast style="max-width: 300px"></Toast>
<div class="background">
<div
class="login-container"
v-motion
:initial="{ opacity: 0, y: 100, scale: 0.75 }"
:enter="{ opacity: 1, y: 0, scale: 1 }"
:variants="{ custom: { scale: 2 } }"
:delay="300"
:duration="1500"
>
<div class="login-items">
<img
src="/banner_colored_transparent.png"
style="width: 200px"
v-motion
:initial="{ opacity: 0, y: 100 }"
:enter="{ opacity: 1, y: 0, scale: 1 }"
:variants="{ custom: { scale: 2 } }"
:delay="200"
:hovered="{ scale: 1.1 }"
:duration="1000"
/>
<form style="justify-content: center; align-items: center" @submit.prevent="handleLogin">
<FloatLabel style="margin-top: 30px">
<InputText id="username" v-model="formData.username" />
<label style="color: white" for="username">Username</label>
</FloatLabel>
<FloatLabel style="margin-top: 30px">
<InputText type="password" id="password" v-model="formData.password" />
<label style="color: white" for="password">Password</label>
</FloatLabel>
<FloatLabel style="margin-top: 30px">
<InputText id="company" v-model="formData.company" />
<label style="color: white" for="company">Company</label>
</FloatLabel>
<Button
type="submit"
style="margin-top: 40px; margin-left: 50px"
label="Login"
severity="secondary"
icon="pi pi-sign-in"
></Button>
</form>
</div>
</div>
<BottomBar v-if="authStore.isAuthenticated" />
</div>
</template>
<style lang="css" scoped>
.background {
background-color: #468c78;
align-items: center;
justify-content: center;
display: grid;
height: calc(100vh);
}
.login-container {
background-color: #34d399;
border-radius: 20px;
box-shadow: 15px 15px 10px #44444489;
height: 70%;
margin-bottom: 20px;
}
.login-items {
padding: 30px;
display: grid;
align-items: center;
justify-content: center;
}
</style>

84
src/views/MenuView.vue Normal file
View File

@ -0,0 +1,84 @@
<script setup lang="ts">
import BottomBar from '../components/BottomBar.vue'
import { Button, ConfirmDialog, Toast } from 'primevue'
import { useAuthStore } from '../stores/authStore'
import { useRouter } from 'vue-router'
const router = useRouter()
const authStore = useAuthStore()
const pinHistoryClicked = () => {
router.push(`/pin-history/${authStore.user._id}`)
}
const usersClicked = () => {
router.push('/users')
}
const teamsClicked = () => {
router.push('/teams')
}
</script>
<template>
<ConfirmDialog></ConfirmDialog>
<Toast style="max-width: 300px" />
<div class="background">
<div v-motion-fade-visible-once class="menu-container">
<div class="menu-items">
<img src="/banner_colored_transparent.png" style="width: 180px" />
<Button
@click="pinHistoryClicked"
icon="pi pi-history"
severity="info"
label="My Pin History"
></Button>
<Button
v-if="authStore.user.role >= 1"
@click="usersClicked"
icon="pi pi-users"
severity="help"
label="Users"
></Button>
<Button
v-if="authStore.user.role >= 2"
@click="teamsClicked"
icon="pi pi-building"
severity="warn"
label="Teams"
></Button>
</div>
</div>
<BottomBar />
</div>
</template>
<style scoped>
.background {
background-color: #468c78;
align-items: center;
justify-content: center;
display: grid;
height: calc(100vh);
}
Button {
box-shadow: 5px 10px 5px #00000080;
}
/* box-shadow: 15px 15px 10px #44444489; */
.menu-container {
background-color: #34d399;
border-radius: 20px;
height: 70%;
margin-bottom: 20px;
box-shadow: 15px 15px 10px #44444489;
}
.menu-items {
padding: 40px;
gap: 30%;
display: grid;
align-items: center;
justify-content: center;
}
</style>

View File

@ -0,0 +1,83 @@
<script setup lang="ts">
import { useAreaStore } from '../stores/areaStore'
import OfficeSelectComponent from '../components/OfficeSelectComponent.vue'
import { useAuthStore } from '../stores/authStore'
import { useOfficeStore } from '../stores/officeStore'
import { usePinStore } from '../stores/pinStore'
import { useRouter } from 'vue-router'
const router = useRouter()
const authStore = useAuthStore()
const officeStore = useOfficeStore()
const pinStore = usePinStore()
const areaStore = useAreaStore()
const setSelectedOffice = async (selectedOfficeId: string) => {
console.log('Selected Office', selectedOfficeId)
officeStore.setActiveOffice(selectedOfficeId)
const fetchedPins = await pinStore.fetchUserPins(authStore.user._id)
pinStore.setUserPins(fetchedPins)
await areaStore.fetchUserAreas()
router.push('/')
}
</script>
<template>
<div class="background">
<div
class="login-container"
v-motion
:initial="{ opacity: 0, y: 100, scale: 0.75 }"
:enter="{ opacity: 1, y: 0, scale: 1 }"
:variants="{ custom: { scale: 2 } }"
:delay="150"
:duration="500"
>
<div class="login-items">
<img
src="/banner_colored_transparent.png"
style="width: 200px"
v-motion
:initial="{ opacity: 0, y: 100 }"
:enter="{ opacity: 1, y: 0, scale: 1 }"
:variants="{ custom: { scale: 2 } }"
:delay="200"
:hovered="{ scale: 1.1 }"
:duration="1000"
/>
<p style="color: black; text-align: center">Select Your Active Team</p>
<OfficeSelectComponent
:offices="authStore.user.offices"
button-severity="secondary"
@formSubmitted="setSelectedOffice"
/>
</div>
</div>
</div>
</template>
<style lang="css" scoped>
.background {
background-color: #468c78;
align-items: center;
justify-content: center;
display: grid;
height: calc(100vh);
}
.login-container {
background-color: #34d399;
border-radius: 20px;
box-shadow: 15px 15px 10px #44444489;
height: 70%;
margin-bottom: 20px;
}
.login-items {
padding: 30px;
display: grid;
align-items: center;
justify-content: center;
}
</style>

View File

@ -0,0 +1,149 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import type { ComponentPublicInstance } from 'vue'
import { useRoute } from 'vue-router'
import { usePinStore } from '../stores/pinStore'
import { useUsersStore } from '../stores/usersStore'
import { useSettingsStore } from '../stores/settingsStore'
import { ConfirmDialog, Button, Toast, InputIcon, IconField, InputText } from 'primevue'
import { useDebounceFn } from '@vueuse/core'
import BottomBar from '../components/BottomBar.vue'
import HeaderBar from '../components/HeaderBar.vue'
import PinHistoryList from '../components/PinHistoryList.vue'
import PinFilterDrawer from '../components/PinFilterDrawer.vue'
import type { Pin } from '../types/pin'
interface PinSettingsDrawerMethods {
toggleDrawerVisible: () => void
}
interface filterSettings {
age?: number
type?: string
zip?: number
state?: string
city?: string
}
const usersStore = useUsersStore()
const settingsStore = useSettingsStore()
const route = useRoute()
const pinStore = usePinStore()
const userId = ref()
const pinFilterDrawerRef = ref<ComponentPublicInstance<PinSettingsDrawerMethods> | null>(null)
const filteredPins = ref<Pin[]>([])
const userPins = ref<Pin[]>([])
const pinSearch = ref('')
const fetchPinsFromId = async (id: string) => {
return await pinStore.fetchUserPins(id)
}
const debounceSearch = useDebounceFn(async () => {
console.log('Search Changed')
if (!pinSearch.value.trim()) {
filteredPins.value = [...userPins.value]
console.log('No Search Found')
} else {
filteredPins.value = [
...userPins.value.filter((pin) =>
pin.name?.toLowerCase().includes(pinSearch.value.toLowerCase())
),
]
console.log('Filtered Pins:', filteredPins.value)
}
}, 500)
const togglePinFilterDrawer = () => {
if (pinFilterDrawerRef.value) {
pinFilterDrawerRef.value.toggleDrawerVisible()
}
}
const handleFiltersChanged = async (event: filterSettings) => {
filteredPins.value = [...userPins.value]
if (event.age) {
settingsStore.pinAgeFilter = event.age.toString()
userPins.value = await fetchPinsFromId(userId.value)
filteredPins.value = [...userPins.value]
}
if (event.type) {
filteredPins.value = [...userPins.value.filter((pin) => pin.type === event.type)]
}
if (event.city) {
filteredPins.value = [
...filteredPins.value.filter((pin) => pin.city?.toLowerCase() === event.city?.toLowerCase()),
]
}
if (event.state) {
filteredPins.value = [
...filteredPins.value.filter(
(pin) => pin.state?.toLowerCase() === event.state?.toLowerCase()
),
]
}
if (event.zip) {
filteredPins.value = [
...filteredPins.value.filter((pin) => pin.zip?.toString() === event.zip?.toString()),
]
}
}
const refreshPins = async () => {
userPins.value = await await pinStore.fetchUserPins(userId.value)
filteredPins.value = [...userPins.value]
}
onMounted(async () => {
userId.value = route.params.id
console.log(userId.value)
userPins.value = await fetchPinsFromId(userId.value)
filteredPins.value = [...userPins.value]
usersStore.activeViewedUser = await usersStore.fetchUserFromId(userId.value)
})
</script>
<template>
<ConfirmDialog />
<Toast style="max-width: 300px" />
<div class="page-container">
<HeaderBar :title="`${usersStore.activeViewedUser.firstName || ''}'s Pin History`" />
<div class="search-bar">
<IconField>
<InputIcon class="pi pi-search" />
<InputText
@value-change="debounceSearch"
v-model="pinSearch"
label="search"
placeholder="Search for a Name"
/>
</IconField>
</div>
<div class="scroll-container">
<PinHistoryList @pin-deleted="refreshPins" :user-pins="filteredPins" />
</div>
</div>
<PinFilterDrawer @filters-changed="handleFiltersChanged" ref="pinFilterDrawerRef" />
<Button
@click="togglePinFilterDrawer"
icon="pi pi-filter"
class="pi p-button-rounded p-button-animated shadow-button filter-button"
severity="help"
size="large"
/>
<BottomBar />
</template>
<style lang="css" scoped>
.filter-button {
position: absolute;
bottom: calc(10% - 2rem);
right: calc(10% - 1rem);
z-index: 1000;
min-width: 44px;
min-height: 44px;
}
</style>

View File

@ -0,0 +1,27 @@
<script setup lang="ts">
import HeaderBar from '../components/HeaderBar.vue'
import EditTeamForm from '../components/EditTeamForm.vue'
import { useRoute } from 'vue-router'
import { onMounted, ref } from 'vue'
import { useOfficeStore } from '../stores/officeStore'
import type { Office } from '../types/office'
const office = ref<Office>()
const officeId = ref()
const route = useRoute()
const officeStore = useOfficeStore()
onMounted(async () => {
officeId.value = route.params.id
office.value = await officeStore.getSingleOffice(officeId.value)
console.log('Got Office:', office.value)
})
</script>
<template>
<HeaderBar title="Edit Team" />
<div class="scroll-container">
<EditTeamForm :office="office" />
</div>
</template>

202
src/views/ViewPinView.vue Normal file
View File

@ -0,0 +1,202 @@
<script setup lang="ts">
import HeaderBar from '../components/HeaderBar.vue'
import { pinGreen, pinRed, pinYellow, pinBlack, pinBlue, pinGray } from '../assets/icons'
import { Button, Toast, ConfirmDialog, InputText, Select, useConfirm, useToast } from 'primevue'
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import type { Pin } from '../types/pin'
import { usePinStore } from '../stores/pinStore'
const pinStore = usePinStore()
const confirm = useConfirm()
const toast = useToast()
const router = useRouter()
const defaultPin: Pin = {
_id: pinStore.selectedPin?._id || '',
coordinates: pinStore.selectedPin?.coordinates || {
type: 'Point',
coordinates: [],
},
owner: pinStore.selectedPin?.owner || '',
office: pinStore.selectedPin?.office || '',
type: pinStore.selectedPin?.type || 'Not Interested',
address: pinStore.selectedPin?.address || '',
city: pinStore.selectedPin?.city || '',
state: pinStore.selectedPin?.state || '',
zip: pinStore.selectedPin?.zip || '',
name: pinStore.selectedPin?.name || '',
phone: pinStore.selectedPin?.phone || '',
email: pinStore.selectedPin?.email || '',
notes: pinStore.selectedPin?.notes || '',
}
const newPin = ref<Pin>(defaultPin)
const typeOptions = ['Not Interested', 'Not Home', 'No Knock', 'Pitched', 'Go Back', 'Sale']
const pinMap: Record<string, string> = {
'Not Interested': pinRed,
Sale: pinGreen,
Pitched: pinBlue,
'Not Home': pinGray,
'No Knock': pinBlack,
'Go Back': pinYellow,
}
const confirmDelete = () => {
confirm.require({
message: 'Are you sure you want to delete this pin?',
header: 'Delete?',
icon: 'pi pi-trash',
rejectLabel: 'Cancel',
rejectProps: {
label: 'Cancel',
severity: 'secondary',
outlined: true,
},
rejectIcon: 'pi pi-times-circle',
acceptProps: {
label: 'Delete',
severity: 'danger',
},
acceptIcon: 'pi pi-trash',
accept: async () => {
const success = await pinStore.deletePin(newPin.value._id)
if (!success) {
toast.add({
severity: 'error',
summary: 'Error!',
detail: 'There was a problem deleting the pin!',
life: 2500,
})
return
}
toast.add({
severity: 'secondary',
summary: 'Deleted!',
detail: 'Deleted Pin!',
life: 2500,
})
setTimeout(() => {
router.back()
}, 2650)
},
reject: () => {},
})
}
const updatePin = async () => {
const success = await pinStore.updatePin(newPin.value)
if (success) {
toast.add({
severity: 'secondary',
summary: 'Updated!',
detail: 'Updated Pin!',
life: 2500,
})
setTimeout(() => {
router.back()
}, 2650)
} else if (!success) {
toast.add({
severity: 'error',
summary: 'Error!',
detail: 'There was a problem updating the pin!',
life: 2500,
})
return
}
}
</script>
<template>
<ConfirmDialog />
<Toast style="max-width: 300px" />
<div class="page-container">
<HeaderBar title="Edit Pin" />
<div class="scroll-container">
<div class="horizontal">
<h3>Created At:</h3>
<h5>{{ new Date(newPin?.createdAt || Date.now()).toLocaleString() || `` }}</h5>
</div>
<div class="form-group" v-motion-pop-visible>
<h3>Type</h3>
<div class="icon-select">
<img class="pin-icon" :src="pinMap[newPin.type]" />
<Select
style="flex-grow: 1"
:options="typeOptions"
v-model="newPin.type"
:default-value="pinStore.selectedPin?.type"
/>
</div>
</div>
<div class="form-group" v-motion-pop-visible>
<h3>Address</h3>
<InputText v-model="newPin.address" />
</div>
<div class="form-group" v-motion-pop-visible>
<h3>City</h3>
<InputText v-model="newPin.city" />
</div>
<div class="form-group" v-motion-pop-visible>
<h3>State</h3>
<InputText v-model="newPin.state" />
</div>
<div class="form-group" v-motion-pop-visible>
<h3>Zip</h3>
<InputText v-model="newPin.zip" />
</div>
<div class="form-group" v-motion-pop-visible>
<h3>Name</h3>
<InputText v-model="newPin.name" />
</div>
<div class="form-group" v-motion-pop-visible>
<h3>Phone</h3>
<InputText v-model="newPin.phone" />
</div>
<div class="form-group" v-motion-pop-visible>
<h3>Email</h3>
<InputText v-model="newPin.email" />
</div>
<div class="form-group" v-motion-pop-visible>
<h3>Notes</h3>
<InputText v-model="newPin.notes" />
</div>
<div class="option-container" style="margin-top: 5%; margin-bottom: 7%" v-motion-pop-visible>
<Button @click="confirmDelete" severity="danger" icon="pi pi-trash" label="Delete" />
<Button @click="updatePin" icon="pi pi-save" label="Save" />
</div>
</div>
</div>
</template>
<style lang="css" scoped>
.horizontal {
display: flex;
width: 100%;
justify-content: space-around;
}
.icon-select {
display: flex;
width: 100%;
flex-grow: 1;
}
</style>

View File

@ -0,0 +1,56 @@
<script setup lang="ts">
import HeaderBar from '../components/HeaderBar.vue'
import PinHistoryButton from '../components/PinHistoryButton.vue'
import EditUserForm from '../components/EditUserForm.vue'
import { ref, onMounted } from 'vue'
import { ConfirmDialog, Toast } from 'primevue'
import { useRoute } from 'vue-router'
import { useAuthStore } from '../stores/authStore'
import { useOfficeStore } from '../stores/officeStore'
import { useUsersStore } from '../stores/usersStore'
import type { Office } from '../types/office'
const authStore = useAuthStore()
const officeStore = useOfficeStore()
const usersStore = useUsersStore()
const route = useRoute()
const userId = ref()
const availableRoles = ref([{ name: 'User', value: 0 }])
const availableOffices = ref<Office[]>(authStore.user.offices)
onMounted(async () => {
userId.value = route.params.id
if (authStore.user.role === 2) {
availableRoles.value.push({ name: 'Team Manager', value: 1 }, { name: 'Admin', value: 2 })
}
if (authStore.user.role === 3) {
availableRoles.value.push(
{ name: 'Team Manager', value: 1 },
{ name: 'Admin', value: 2 },
{ name: 'System Admin', value: 3 }
)
}
if (authStore.user.role >= 2) {
await officeStore.getAllOffices()
availableOffices.value = [...officeStore.allOffices]
}
})
</script>
<template>
<ConfirmDialog />
<Toast style="max-width: 300px" />
<div class="page-container">
<HeaderBar title="Edit User" />
<div class="scroll-container">
<EditUserForm
:possible-offices="availableOffices"
:possible-roles="availableRoles"
:possible-supervisors="usersStore.allUsers"
></EditUserForm>
</div>
</div>
<PinHistoryButton :user-id="userId" style="z-index: 1000" />
</template>

2
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,2 @@
/// <reference types="vite/client" />
/// <reference types="vite-plugin-pwa/vue" />

27
tsconfig.app.json Normal file
View File

@ -0,0 +1,27 @@
// {
// "extends": "@vue/tsconfig/tsconfig.dom.json",
// "compilerOptions": {
// "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
// /* Linting */
// "strict": true,
// "noUnusedLocals": true,
// "noUnusedParameters": true,
// "noFallthroughCasesInSwitch": true,
// "noUncheckedSideEffectImports": true
// },
// "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
// }
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"paths": {
"@/*": ["./src/*"]
}
}
}

7
tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

25
tsconfig.node.json Normal file
View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

59
vite.config.ts Normal file
View File

@ -0,0 +1,59 @@
import { VitePWA } from 'vite-plugin-pwa';
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue(), VitePWA({
registerType: 'autoUpdate',
injectRegister: false,
pwaAssets: {
disabled: false,
config: true,
},
manifest: {
name: 'justCanvas',
short_name: 'justCanvas',
description: 'Make Canvassing Easy!',
theme_color: '#ffffff',
},
workbox: {
globPatterns: ['**/*.{js,css,html,svg,png,ico}'],
cleanupOutdatedCaches: true,
clientsClaim: true,
maximumFileSizeToCacheInBytes: 3000000
},
devOptions: {
enabled: false,
navigateFallback: 'index.html',
suppressWarnings: true,
type: 'module',
},
})],
preview: {
allowedHosts: true,
https: {
key: './src/keys/private-key.pem',
cert: './src/keys/certificate.pem'
}
},
server: {
allowedHosts: true,
https: {
key: './src/keys/private-key.pem',
cert: './src/keys/certificate.pem',
}
},
define: {
__VUE_PROD_DEVTOOLS__: true
}
})