Added trainer and center admin views
continuous-integration/drone/push Build is passing Details

main
Filip Borum Poulsen 3 years ago
parent 0dfbc69f96
commit 016a01614a

@ -0,0 +1,106 @@
<template>
<div class="popup" @click="$emit('closePopup')">
<div class="Center" @click.stop>
<label for="name">Navn:</label>
<input type="text" v-model="center.name" name="name">
<label for="city">By:</label>
<input type="text" v-model="center.city" name="city">
<label for="zip_code">Postnummer:</label>
<input type="text" v-model="center.zip_code" name="zip_code">
<label for="address">Adresse:</label>
<input type="text" v-model="center.address" name="address">
<div class="updateButton" @click="updateCenter">Opdater</div>
<div class="closeButton" @click="$emit('closePopup')">x</div>
</div>
</div>
</template>
<style scoped>
.popup {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #000a;
display: flex;
justify-content: center;
align-items: center;
z-index: 10;
}
.Center {
background-color: white;
border-radius: 0.5rem;
padding: 30px;
display: flex;
flex-direction: column;
min-width: 50%;
position: relative;
gap: 5px;
}
.updateButton {
border: 1px solid #cbd5e1;
border-radius: 0.5rem;
padding: 10px;
cursor: pointer;
font-size: 1.25em;
width: fit-content;
align-self: center;
}
.updateButton:hover {
text-decoration: underline;
border-color: black;
}
.closeButton {
position: absolute;
top: 10px;
right: 20px;
font-size: 2em;
cursor: pointer;
}
.closeButton:hover {
text-decoration: underline;
}
</style>
<script lang="ts">
import type { Center } from '@/interfaces/center';
import type { PropType } from 'vue';
export default {
name: "CenterPopup",
emits: ["closePopup", "fetchCenters"],
props: {
center: { type: Object as PropType<Center>, required: true }
},
methods: {
async updateCenter() {
const res = await fetch(`${import.meta.env.VITE_BASE_API_URL}/center/${this.center.id}`, {
credentials: import.meta.env.DEV ? "include" : undefined,
method: "PUT",
body: JSON.stringify({
name: this.center.name,
city: this.center.city,
zip_code: this.center.zip_code,
address: this.center.address
}),
headers: {
"Content-Type": "Application/json"
}
});
if (res.status === 401) {
this.$router.push({ path: "/login", query: { ref: this.$route.path } });
}
else if (res.status === 200) {
this.$emit("fetchCenters");
this.$emit("closePopup");
}
}
}
}
</script>

@ -52,8 +52,8 @@ export default {
<RouterLink to="/user/orders" v-if="loggedIn && !user.isTrainer">Træningstimer</RouterLink>
<RouterLink to="/trainer/orders" v-if="loggedIn && user.isTrainer">Træningstimer</RouterLink>
<RouterLink to="/trainer/schedule" v-if="loggedIn && user.isTrainer">Skema</RouterLink>
<RouterLink to="/admin/trainer" v-if="loggedIn && user.isAdmin">Centers</RouterLink>
<RouterLink to="/admin/center" v-if="loggedIn && user.isAdmin">Trainers</RouterLink>
<RouterLink to="/admin/trainer" v-if="loggedIn && user.isAdmin">Trænere</RouterLink>
<RouterLink to="/admin/center" v-if="loggedIn && user.isAdmin">Centre</RouterLink>
<RouterLink to="/user/profile" v-if="loggedIn">Profil</RouterLink>
</div>
</div>

@ -0,0 +1,104 @@
<template>
<div class="popup" @click="$emit('closePopup')">
<div class="Trainer" @click.stop>
<label for="price">Timepris (øre):</label>
<input type="number" min="0" step="100" v-model="trainer.hourly_price" name="price">
<label for="center">Center:</label>
<select v-model="trainer.center_id" name="center">
<option v-for="center of centers" :value="center.id">{{ center.name }}</option>
</select>
<div class="updateButton" @click="updateTrainer">Opdater</div>
<div class="closeButton" @click="$emit('closePopup')">x</div>
</div>
</div>
</template>
<style scoped>
.popup {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #000a;
display: flex;
justify-content: center;
align-items: center;
z-index: 10;
}
.Trainer {
background-color: white;
border-radius: 0.5rem;
padding: 30px;
display: flex;
flex-direction: column;
min-width: 50%;
position: relative;
gap: 5px;
}
.updateButton {
border: 1px solid #cbd5e1;
border-radius: 0.5rem;
padding: 10px;
cursor: pointer;
font-size: 1.25em;
width: fit-content;
align-self: center;
}
.updateButton:hover {
text-decoration: underline;
border-color: black;
}
.closeButton {
position: absolute;
top: 10px;
right: 20px;
font-size: 2em;
cursor: pointer;
}
.closeButton:hover {
text-decoration: underline;
}
</style>
<script lang="ts">
import type { Center } from '@/interfaces/center';
import type { TrainerWithHourlyPrice as Trainer } from '@/interfaces/trainer';
import type { PropType } from 'vue';
export default {
name: "TrainerPopup",
emits: ["closePopup", "fetchTrainers"],
props: {
trainer: { type: Object as PropType<Trainer>, required: true },
centers: { type: Object as PropType<Center[]>, required: true },
},
methods: {
async updateTrainer() {
const res = await fetch(`${import.meta.env.VITE_BASE_API_URL}/trainer/${this.trainer.id}`, {
credentials: import.meta.env.DEV ? "include" : undefined,
method: "PUT",
body: JSON.stringify({
center_id: this.trainer.center_id,
hourly_price: this.trainer.hourly_price
}),
headers: {
"Content-Type": "Application/json"
}
});
if (res.status === 401) {
this.$router.push({ path: "/login", query: { ref: this.$route.path } });
}
else if (res.status === 200) {
this.$emit("fetchTrainers");
this.$emit("closePopup");
}
}
}
}
</script>

@ -4,4 +4,8 @@ export interface Trainer {
last_name: string
center_id: number
center_name: string
}
export interface TrainerWithHourlyPrice extends Trainer {
hourly_price: number
}

@ -48,6 +48,22 @@ const router = createRouter({
// this generates a separate chunk (About.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import('../views/trainer/TrainerOrdersView.vue')
},
{
path: '/admin/center',
name: 'AdminCenters',
// route level code-splitting
// this generates a separate chunk (About.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import('../views/admin/Centers.vue')
},
{
path: '/admin/trainer',
name: 'AdminTrainers',
// route level code-splitting
// this generates a separate chunk (About.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import('../views/admin/Trainers.vue')
}
]
})

@ -0,0 +1,119 @@
<template>
<div class="AdminCenters">
<div class="centers">
<div class="center" v-for="center of centers" :v-key="center.id" @click="showPopup(center)">
<div class="name">{{ center.name }}</div>
<div class="city">{{ center.city }}, {{ center.zip_code }}</div>
<div class="address">{{ center.address }}</div>
</div>
</div>
<div class="newCenter">
<label for="name">Navn:</label>
<input type="text" v-model="name" name="name">
<label for="city">By:</label>
<input type="text" v-model="city" name="city">
<label for="zip_code">Postnummer:</label>
<input type="text" v-model="zip_code" name="zip_code">
<label for="address">Adresse:</label>
<input type="text" v-model="address" name="address">
<input type="submit" value="Tilføj" @click="addCenter">
</div>
<CenterPopup :center="selectedCenter" @fetchCenters="fetchCenters" @closePopup="popupShown = false"
v-if="popupShown"></CenterPopup>
</div>
</template>
<style scoped>
.AdminCenters {
display: flex;
flex-direction: column;
align-items: center;
gap: 50px;
}
.newCenter {
display: flex;
flex-direction: column;
width: fit-content;
gap: 5px;
}
.centers {
display: flex;
gap: 30px;
justify-content: center;
flex-wrap: wrap;
}
.center {
border: 1px solid #cbd5e1;
border-radius: 0.5rem;
padding: 20px;
cursor: pointer;
}
.center:hover {
border-color: black;
}
.name {
font-size: 1.5rem;
}
</style>
<script lang="ts">
import CenterPopup from '@/components/CenterPopup.vue'
import type { Center } from '@/interfaces/center';
export default {
name: "AdminCenters",
components: { CenterPopup },
data() {
return {
name: "",
city: "",
zip_code: "",
address: "",
popupShown: false,
selectedCenter: {} as Center,
centers: [] as Center[]
};
},
async created() {
await this.fetchCenters();
},
methods: {
showPopup(center: Center) {
this.selectedCenter = { ...center };
this.popupShown = true;
},
async addCenter() {
const res = await fetch(`${import.meta.env.VITE_BASE_API_URL}/center`, {
credentials: import.meta.env.DEV ? "include" : undefined,
method: "POST",
body: JSON.stringify({
name: this.name,
city: this.city,
zip_code: this.zip_code,
address: this.address
}),
headers: {
"Content-Type": "Application/json"
}
});
if (res.status === 401) {
this.$router.push({ path: "/login", query: { ref: this.$route.path } });
}
else if (res.status === 200) {
this.fetchCenters();
}
},
async fetchCenters() {
const res = await fetch(`${import.meta.env.VITE_BASE_API_URL}/center`);
if (res.status === 200) {
this.centers = await res.json();
}
}
}
}
</script>

@ -0,0 +1,136 @@
<template>
<div class="AdminTrainers">
<div class="trainers">
<div class="trainer" v-for="trainer of trainers" :v-key="trainer.id" @click="showPopup(trainer)">
<div class="name">{{ trainer.first_name }} {{ trainer.last_name }}</div>
<div class="center">{{ trainer.center_name }}</div>
<div class="price">{{ formatPrice(trainer.hourly_price) }}</div>
</div>
</div>
<div class="newTrainer">
<label for="email">Email:</label>
<input type="text" v-model="email" name="email">
<label for="price">Timepris (øre):</label>
<input type="number" min="0" step="100" v-model="hourly_price" name="price">
<label for="center">Center:</label>
<select v-model="center" name="center">
<option v-for="center of centers" :value="center.id">{{ center.name }}</option>
</select>
<input type="submit" value="Tilføj" @click="addTrainer">
</div>
<TrainerPopup :centers="centers" :trainer="selectedTrainer" @fetchTrainers="fetchTrainers"
@closePopup="popupShown = false" v-if="popupShown"></TrainerPopup>
</div>
</template>
<style scoped>
.AdminTrainers {
display: flex;
flex-direction: column;
align-items: center;
gap: 50px;
}
.newTrainer {
display: flex;
flex-direction: column;
width: fit-content;
gap: 5px;
}
.trainers {
display: flex;
gap: 30px;
justify-content: center;
flex-wrap: wrap;
}
.trainer {
border: 1px solid #cbd5e1;
border-radius: 0.5rem;
padding: 20px;
cursor: pointer;
}
.trainer:hover {
border-color: black;
}
.name {
font-size: 1.5rem;
}
</style>
<script lang="ts">
import TrainerPopup from '@/components/TrainerPopup.vue'
import type { Center } from '@/interfaces/center';
import type { TrainerWithHourlyPrice as Trainer } from '@/interfaces/trainer';
export default {
name: "AdminCenters",
components: { TrainerPopup },
data() {
return {
popupShown: false,
selectedTrainer: {} as Trainer,
trainers: [] as Trainer[],
centers: [] as Center[],
center: -1,
email: "",
hourly_price: 0
};
},
async created() {
this.fetchCenters();
await this.fetchTrainers();
},
methods: {
showPopup(trainer: Trainer) {
this.selectedTrainer = { ...trainer };
this.popupShown = true;
},
async addTrainer() {
const res = await fetch(`${import.meta.env.VITE_BASE_API_URL}/trainer`, {
credentials: import.meta.env.DEV ? "include" : undefined,
method: "POST",
body: JSON.stringify({
center_id: this.center,
email: this.email,
hourly_price: this.hourly_price
}),
headers: {
"Content-Type": "Application/json"
}
});
if (res.status === 401) {
this.$router.push({ path: "/login", query: { ref: this.$route.path } });
}
else if (res.status === 204) {
this.fetchTrainers();
}
},
async fetchTrainers() {
const res = await fetch(`${import.meta.env.VITE_BASE_API_URL}/admin/trainer`, {
credentials: import.meta.env.DEV ? "include" : undefined
});
if (res.status === 200) {
this.trainers = await res.json();
}
},
async fetchCenters() {
const res = await fetch(`${import.meta.env.VITE_BASE_API_URL}/center`);
if (res.status === 200) {
this.centers = await res.json();
}
},
formatPrice(price: number): string {
const DanishKrone = new Intl.NumberFormat('dk', {
style: 'currency',
currency: 'DKK',
});
return DanishKrone.format(price / 100);
}
}
}
</script>

@ -9,6 +9,7 @@
| [x] | PUT | /center/:id | Admin can change center | Admin |
| [ ] | DELETE | /center | Admin can delete center | Admin |
| [x] | GET | /trainer | Get list of trainers | |
| [x] | GET | /admin/trainer | Get list of trainers with hourly price | Admin |
| [x] | POST | /trainer | Admin can create trainer | Admin |
| [ ] | PUT | /trainer | Trainer can change profile information | Trainer |
| [x] | PUT | /trainer/:id | Admin can change trainer information | Admin |

@ -5,7 +5,7 @@ export async function trainerExists(req: Request, res: Response, next: NextFunct
const trainerExistsResult = await client.query(`
SELECT 1 FROM trainers where id = $1;
`, [
req.params.trainer_id
req.params.id
]);
if (trainerExistsResult.rowCount < 1) {

@ -20,6 +20,10 @@ interface TrainerFilters {
center?: number[]
}
interface TrainerWithHourlyPrice extends Trainer {
hourly_price: number
}
router.get("/trainer", async (req: Request, res: Response) => {
try {
const validation = trainerFiltersSchema.validate(req.query, { abortEarly: false, stripUnknown: true });
@ -33,8 +37,9 @@ router.get("/trainer", async (req: Request, res: Response) => {
SELECT trainers.id, first_name, last_name, center_id, centers.name as center_name FROM trainers
JOIN users ON trainers.user_id = users.id
JOIN centers on trainers.center_id = centers.id
WHERE trainers.id = ANY($1) OR $2;
`,[
WHERE trainers.id = ANY($1) OR $2
ORDER BY first_name ASC;
`, [
trainerFilters.center !== undefined ? trainerFilters.center : [],
trainerFilters.center === undefined
]);
@ -48,8 +53,44 @@ router.get("/trainer", async (req: Request, res: Response) => {
}
})
router.get("/admin/trainer", AdminAuth, async (req: Request, res: Response) => {
try {
const validation = trainerFiltersSchema.validate(req.query, { abortEarly: false, stripUnknown: true });
if (validation.error !== undefined) {
return res.status(400).send(validation.error.details);
}
const trainerFilters: TrainerFilters = validation.value;
const databaseResult = await client.query(`
SELECT
trainers.id,
first_name,
last_name,
center_id,
centers.name as center_name,
hourly_price
FROM trainers
JOIN users ON trainers.user_id = users.id
JOIN centers on trainers.center_id = centers.id
WHERE trainers.id = ANY($1) OR $2
ORDER BY first_name ASC;
`, [
trainerFilters.center !== undefined ? trainerFilters.center : [],
trainerFilters.center === undefined
]);
const trainers: TrainerWithHourlyPrice[] = databaseResult.rows;
return res.status(200).send(trainers);
} catch (error: DatabaseError | Error | any) {
console.error(error);
return res.sendStatus(500);
}
})
const createTrainerSchema = Joi.object({
user_id: Joi.number().integer().positive().required(),
email: Joi.string().email().required(),
center_id: Joi.number().integer().positive().required(),
hourly_price: Joi.number().integer().positive().required()
});
@ -61,24 +102,34 @@ router.post("/trainer", AdminAuth, async (req: Request, res: Response) => {
return res.status(400).send(validation.error.details);
}
const databaseResult = await client.query(`
WITH inserted AS (
INSERT INTO trainers (user_id, center_id, hourly_price)
VALUES ($1, $2, $3)
RETURNING *
)
SELECT inserted.id, user_id, first_name, last_name, center_id, centers.name as center_name from inserted
JOIN users ON inserted.user_id = users.id
JOIN centers on inserted.center_id = centers.id;
const trainerLookup = await client.query(`
SELECT id FROM users
WHERE email = $1;
`, [
validation.value.email
]);
if (trainerLookup.rows.length !== 1)
return res.status(400).send([{
message: "\"email\" was not found",
path: [
"email"
],
type: "email.not_found"
}]);
const user_id = trainerLookup.rows[0].id;
await client.query(`
INSERT INTO trainers (user_id, center_id, hourly_price)
VALUES ($1, $2, $3);
`, [
validation.value.user_id,
user_id,
validation.value.center_id,
validation.value.hourly_price
]);
const trainers: Trainer[] = databaseResult.rows;
return res.status(200).send(trainers);
return res.sendStatus(204);
} catch (error: DatabaseError | Error | any) {
if (error.constraint == "trainers_user_id_key") {
return res.status(400).send([{
@ -108,7 +159,7 @@ const updateTrainerSchema = Joi.object({
hourly_price: Joi.number().integer().positive().required()
});
router.put("/trainer/:trainer_id", AdminAuth, trainerExists, async (req: Request, res: Response) => {
router.put("/trainer/:id", AdminAuth, trainerExists, async (req: Request, res: Response) => {
try {
const validation = updateTrainerSchema.validate(req.body, { abortEarly: false });
if (validation.error !== undefined) {
@ -132,9 +183,9 @@ router.put("/trainer/:trainer_id", AdminAuth, trainerExists, async (req: Request
req.params.id
]);
const trainers: Trainer[] = databaseResult.rows;
const trainer: Trainer = databaseResult.rows[0];
return res.status(200).send(trainers);
return res.status(200).send(trainer);
} catch (error: DatabaseError | Error | any) {
if (error.constraint == "trainers_center_id_fkey") {
return res.status(400).send([{

Loading…
Cancel
Save