diff --git a/cypress/downloads/downloads.htm b/cypress/downloads/downloads.htm deleted file mode 100644 index 7df700b8dcd760fb4e36f087449ae1fb4162a59c..0000000000000000000000000000000000000000 Binary files a/cypress/downloads/downloads.htm and /dev/null differ diff --git a/package-lock.json b/package-lock.json index 2861813d1d2391c020a3df5974a5f8a63525c6c5..962f0ccbb36ff69aa08fc85c6ff4ba24d66f855f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,8 @@ "canvas-confetti": "^1.9.2", "pinia": "^2.1.7", "vue": "^3.4.21", - "vue-router": "^4.3.1" + "vue-router": "^4.3.1", + "vuedraggable": "^4.1.0" }, "devDependencies": { "@rushstack/eslint-patch": "^1.8.0", @@ -6157,6 +6158,11 @@ "node": ">=8" } }, + "node_modules/sortablejs": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.14.0.tgz", + "integrity": "sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==" + }, "node_modules/source-map-js": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", @@ -7246,6 +7252,17 @@ "typescript": "*" } }, + "node_modules/vuedraggable": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/vuedraggable/-/vuedraggable-4.1.0.tgz", + "integrity": "sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww==", + "dependencies": { + "sortablejs": "1.14.0" + }, + "peerDependencies": { + "vue": "^3.0.1" + } + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", diff --git a/package.json b/package.json index 68388497c7d873b8f03e1128b07bb1c1c5a21ef9..be13c2a5d3e4a0451de2749309b751fd1a3e17b4 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "canvas-confetti": "^1.9.2", "pinia": "^2.1.7", "vue": "^3.4.21", - "vue-router": "^4.3.1" + "vue-router": "^4.3.1", + "vuedraggable": "^4.1.0" }, "devDependencies": { "@rushstack/eslint-patch": "^1.8.0", diff --git a/src/assets/base.css b/src/assets/base.css index b70ce8c50bc1afbb76d12643070e61a8574ffeda..3474f444941f8de331422c1d5f2561ddd56854e7 100644 --- a/src/assets/base.css +++ b/src/assets/base.css @@ -6,6 +6,7 @@ --grey: #cbcbcb; --light-grey: #f2f2f2; --green: #95e35d; + --red: #ef9691; --light-green: #b3f385; --bright: #f7da7c; diff --git a/src/components/CardChallenge.vue b/src/components/CardChallenge.vue new file mode 100644 index 0000000000000000000000000000000000000000..dfbf2de5414580d015471191ad17d1aae657504d --- /dev/null +++ b/src/components/CardChallenge.vue @@ -0,0 +1,40 @@ +<script lang="ts" setup> +import { computed, type PropType } from 'vue' +import ProgressBar from '@/components/ProgressBar.vue' +import router from '@/router' +import type { Challenge } from '@/types/challenge' + +const props = defineProps({ + challengeInstance: { + type: Object as PropType<Challenge>, + default: () => ({ + id: 0, + title: 'Challenge title', + saved: 500, + target: 1000, + description: 'challenge Description', + due: '2021-12-31' + }) + } +}) + +const challengeInstance = props.challengeInstance + +const editChallenge = () => + router.push({ name: 'edit-challenge', params: { id: challengeInstance.id } }) +const displayDate = computed(() => challengeInstance.due?.slice(0, 16).split('T').join(' ')) +</script> + +<template> + <div + class="border-2 border-black rounded-xl p-4 flex flex-col items-center gap-2 cursor-pointer" + @click="editChallenge" + > + <h2 class="m-0">{{ challengeInstance.title }}</h2> + <p>{{ challengeInstance.saved }}kr / {{ challengeInstance.target }}kr</p> + <ProgressBar :completion="challengeInstance.completion" /> + <p>{{ displayDate }}</p> + </div> +</template> + +<style scoped></style> diff --git a/src/components/CardGoal.vue b/src/components/CardGoal.vue new file mode 100644 index 0000000000000000000000000000000000000000..09d1241b1d2a613733865f5fefee1fb1c0f8cc1d --- /dev/null +++ b/src/components/CardGoal.vue @@ -0,0 +1,42 @@ +<script lang="ts" setup> +import type { Goal } from '@/types/goal' +import { computed, type PropType, reactive } from 'vue' +import ProgressBar from '@/components/ProgressBar.vue' +import router from '@/router' + +const props = defineProps({ + goalInstance: { + type: Object as PropType<Goal>, + default: () => ({ + id: 0, + title: 'Goal Title', + saved: 500, + target: 1000, + completion: 50, + description: 'Goal Description', + priority: 0, + createdOn: '2021-01-01', + due: '2021-12-31' + }) + } +}) + +const goalInstance = reactive(props.goalInstance) + +const editGoal = () => router.push({ name: 'edit-goal', params: { id: goalInstance.id } }) +const displayDate = computed(() => goalInstance.due?.slice(0, 16).split('T').join(' ')) +</script> + +<template> + <div + class="border-2 border-black rounded-xl p-4 flex flex-col items-center gap-2 cursor-pointer" + @click="editGoal" + > + <h2 class="m-0">{{ goalInstance.title }}</h2> + <p>{{ goalInstance.saved }}kr / {{ goalInstance.target }}kr</p> + <ProgressBar :completion="goalInstance.completion" /> + <p>{{ displayDate }}</p> + </div> +</template> + +<style scoped></style> diff --git a/src/components/ContinueButtonComponent.vue b/src/components/ContinueButtonComponent.vue index 8905468de41b630dc9223c5256a1ab73402d1955..54a0825553b94e865ab72579d7e64c5400fed7fa 100644 --- a/src/components/ContinueButtonComponent.vue +++ b/src/components/ContinueButtonComponent.vue @@ -10,7 +10,7 @@ </template> <script setup lang="ts"> -import { defineProps, defineEmits } from 'vue' +import { defineEmits, defineProps } from 'vue' const props = defineProps({ disabled: Boolean diff --git a/src/components/NavBarComponent.vue b/src/components/NavBarComponent.vue index b58aa12b56956f2fb2419265d48ce75bb0a37466..bef306e26ec0414256225e1eecc6b91d0673ea23 100644 --- a/src/components/NavBarComponent.vue +++ b/src/components/NavBarComponent.vue @@ -68,7 +68,7 @@ <script setup lang="ts"> import { RouterLink, RouterView, useRoute, useRouter } from 'vue-router' -import { computed, ref, onMounted } from 'vue' +import { computed, onMounted, ref } from 'vue' const route = useRoute() const router = useRouter() diff --git a/src/components/PageControl.vue b/src/components/PageControl.vue new file mode 100644 index 0000000000000000000000000000000000000000..91edf78e6385d200e3a62621fb02201e2b337888 --- /dev/null +++ b/src/components/PageControl.vue @@ -0,0 +1,30 @@ +<script lang="ts" setup> +defineProps({ + currentPage: { + type: Number, + default: 1 + }, + totalPages: { + type: Number, + default: 1 + }, + onPageChange: { + type: Function, + default: () => {} + } +}) +</script> + +<template> + <div class="flex justify-center gap-4"> + <button :disabled="currentPage === 1" @click="onPageChange(currentPage - 1)"> + Previous + </button> + <p>{{ currentPage }} / {{ totalPages }}</p> + <button :disabled="currentPage === totalPages" @click="onPageChange(currentPage + 1)"> + Next + </button> + </div> +</template> + +<style scoped></style> diff --git a/src/components/ProgressBar.vue b/src/components/ProgressBar.vue new file mode 100644 index 0000000000000000000000000000000000000000..770c75b9bbd250fa7c55ae5d4b1cdbd11ea3312b --- /dev/null +++ b/src/components/ProgressBar.vue @@ -0,0 +1,13 @@ +<script lang="ts" setup> +defineProps({ + completion: Number +}) +</script> + +<template> + <div class="w-full bg-gray-200 rounded-full overflow-hidden"> + <div :style="{ width: completion + '%' }" class="bg-green-500 h-2 rounded-full"></div> + </div> +</template> + +<style scoped></style> diff --git a/src/components/__tests__/ContinueButtonTest.spec.ts b/src/components/__tests__/ContinueButtonTest.spec.ts index 9440d848118e74c4646c9dd43863800756f89cf4..cc9285c7abb1bfbd9fec3b5b308865068fec964c 100644 --- a/src/components/__tests__/ContinueButtonTest.spec.ts +++ b/src/components/__tests__/ContinueButtonTest.spec.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest' +import { describe, expect, it } from 'vitest' import { mount } from '@vue/test-utils' import ContinueButtonComponent from '@/components/ContinueButtonComponent.vue' diff --git a/src/components/__tests__/ModalTest.spec.ts b/src/components/__tests__/ModalTest.spec.ts index 80f93265e199899e02ff5dbb1e6824cfaa5112ae..146c89975aec3406a97d723e5619614ff38e2cb2 100644 --- a/src/components/__tests__/ModalTest.spec.ts +++ b/src/components/__tests__/ModalTest.spec.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach } from 'vitest' +import { beforeEach, describe, expect, it } from 'vitest' import { shallowMount } from '@vue/test-utils' import ModalComponent from '@/components/ModalComponent.vue' diff --git a/src/router/index.ts b/src/router/index.ts index 9a6758c7dcc2a73e732cb6d0173a96ea5f09e4ac..93d137b48bbadd87371d503ce071839867dc827e 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -1,5 +1,4 @@ import { createRouter, createWebHistory } from 'vue-router' -import HomeView from '../views/HomeView.vue' const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), @@ -12,7 +11,7 @@ const router = createRouter({ { path: '/hjem', name: 'home', - component: HomeView + component: () => import('@/views/HomeView.vue') }, { path: '/logginn', @@ -37,17 +36,32 @@ const router = createRouter({ { path: '/sparemaal', name: 'goals', - component: () => import('@/views/GoalView.vue') + component: () => import('@/views/UserGoalsView.vue') + }, + { + path: '/sparemaal/ny', + name: 'new-goal', + component: () => import('@/views/ManageGoalView.vue') + }, + { + path: '/sparemaal/:id', + name: 'edit-goal', + component: () => import('@/views/ManageGoalView.vue') }, { path: '/spareutfordringer', name: 'challenges', - component: () => import('@/views/ChallengeView.vue') + component: () => import('@/views/UserChallengesView.vue') }, { - path: '/:pathMatch(.*)*', - name: 'not-found', - component: () => import('@/views/NotFoundView.vue') + path: '/spareutfordringer/ny', + name: 'new-challenge', + component: () => import('@/views/ManageChallengeView.vue') + }, + { + path: '/spareutfordringer/:id', + name: 'edit-challenge', + component: () => import('@/views/ManageChallengeView.vue') }, { path: '/konfigurasjonSteg1', @@ -78,6 +92,11 @@ const router = createRouter({ path: '/forsteSpareutfordring', name: 'firstSavingChallengde', component: () => import('@/views/FirstSavingChallengeView.vue') + }, + { + path: '/:pathMatch(.*)*', + name: 'not-found', + component: () => import('@/views/NotFoundView.vue') } ], scrollBehavior(to, from, savedPosition) { diff --git a/src/services/authInterceptor.ts b/src/services/authInterceptor.ts index 087f27d9bea3f2d2cd6b0dbe83e756f63204a386..f2fd74ff880df5b430d9dadca83cf38b973c3449 100644 --- a/src/services/authInterceptor.ts +++ b/src/services/authInterceptor.ts @@ -3,7 +3,10 @@ import axios, { AxiosError } from 'axios' import router from '@/router' const authInterceptor = axios.create({ - baseURL: 'http://localhost:8080' + baseURL: 'http://localhost:8080', + headers: { + 'Content-Type': 'application/json' + } }) authInterceptor.interceptors.request.use( @@ -29,7 +32,11 @@ authInterceptor.interceptors.response.use( originalRequest._retry = true const refreshToken = localStorage.getItem('refreshToken') axios - .post('/auth/renewToken', { refreshToken }) + .post('/auth/renewToken', null, { + headers: { + Authorization: `Bearer ${refreshToken}` + } + }) .then((response) => { sessionStorage.setItem('accessToken', response.data.accessToken) authInterceptor.defaults.headers['Authorization'] = diff --git a/src/types/challenge.ts b/src/types/challenge.ts index cfdc5c5d3bf7430a0182bb55dfdc7711f424365c..ef9acee9b95f6d9ba0b28103b6981461bb1d8b83 100644 --- a/src/types/challenge.ts +++ b/src/types/challenge.ts @@ -1,15 +1,15 @@ // Assuming the use of classes from 'class-transformer' for date handling or plain TypeScript export interface Challenge { - id: number + id?: number title: string + perPurchase: number saved: number // BigDecimal in Java, but TypeScript uses number for floating points target: number - perPurchase: number description: string - createdOn: Date // Mapping ZonedDateTime to Date - dueDate?: Date // Mapping ZonedDateTime to Date, optional since Temporal annotation not always implies required - type?: string // Not specified as @NotNull, so it's optional + due: string // Mapping ZonedDateTime to Date, optional since Temporal annotation not always implies required + createdOn?: string // Mapping ZonedDateTime to Date + type: string // Not specified as @NotNull, so it's optional completion?: number // Assuming BigDecimal maps to number, optional due to @Transient - completedOn?: Date // Adding the new variable as optional + completedOn?: string // Adding the new variable as optional } diff --git a/src/types/goal.ts b/src/types/goal.ts index 910d9f4359473f9b92a32dd7098a686e2784e5d6..3ad1769cd1112d5b29c4769fce17997b28a05fa8 100644 --- a/src/types/goal.ts +++ b/src/types/goal.ts @@ -1,44 +1,12 @@ export interface Goal { - /** The unique identifier for the Goal, must not be null. */ - id: number - - /** - * The title of the Goal, must not be null, empty, or only whitespace. - */ + id: number | null | undefined title: string - - /** - * The amount saved towards the Goal so far. Must not be null and must be zero or positive. - */ saved: number - - /** - * The target amount to achieve for the Goal. Must not be null and must be positive. - */ target: number - - /** - * Completion percentage of the Goal. Must not be null and must be zero or positive. - */ completion: number - - /** - * A description of the Goal, must not be null, empty, or only whitespace. - */ description: string - - /** - * The priority of the Goal, must not be null and must be zero or positive. - */ priority: number - - /** - * The date and time when the Goal was created. Must be a date in the past. - */ - createdOn: Date - - /** - * The date and time by which the Goal is due. Must be a date in the future. - */ - due: Date + createdOn: string | null | undefined + due: string + completedOn: string | null | undefined } diff --git a/src/views/ConfigSpendingItemsAmountView.vue b/src/views/ConfigSpendingItemsAmountView.vue index 8146317360f08c970b08df5a793addea913dfb10..46d8a5b02c6ef5f7fb6858d47257badf050e68e2 100644 --- a/src/views/ConfigSpendingItemsAmountView.vue +++ b/src/views/ConfigSpendingItemsAmountView.vue @@ -62,7 +62,7 @@ </template> <script setup lang="ts"> -import { computed, ref } from 'vue' +import { ref } from 'vue' import ContinueButtonComponent from '@/components/ContinueButtonComponent.vue' import router from '@/router' diff --git a/src/views/ConfigSpendingItemsTotalAmountView.vue b/src/views/ConfigSpendingItemsTotalAmountView.vue index 0f8d51b5d4cb80e1253864260d588c050c82ae4f..e872697fbe7dc8b9739bd3c2d81ac4778668d42e 100644 --- a/src/views/ConfigSpendingItemsTotalAmountView.vue +++ b/src/views/ConfigSpendingItemsTotalAmountView.vue @@ -62,7 +62,7 @@ </template> <script setup lang="ts"> -import { computed, ref } from 'vue' +import { ref } from 'vue' import ContinueButtonComponent from '@/components/ContinueButtonComponent.vue' import router from '@/router' diff --git a/src/views/GoalView.vue b/src/views/GoalView.vue deleted file mode 100644 index 8539abf5c1c859e60bd8e9fe1c8abcc77426b875..0000000000000000000000000000000000000000 --- a/src/views/GoalView.vue +++ /dev/null @@ -1,7 +0,0 @@ -<script lang="ts" setup></script> - -<template> - <h1>SparemÃ¥l</h1> -</template> - -<style scoped></style> diff --git a/src/views/ManageChallengeView.vue b/src/views/ManageChallengeView.vue new file mode 100644 index 0000000000000000000000000000000000000000..b6c0c7ab2da794dea04f2e27e6eb865efe42e9a4 --- /dev/null +++ b/src/views/ManageChallengeView.vue @@ -0,0 +1,274 @@ +<script lang="ts" setup> +import { useRouter } from 'vue-router' +import { computed, onMounted, ref, watch } from 'vue' +import ProgressBar from '@/components/ProgressBar.vue' +import authInterceptor from '@/services/authInterceptor' +import type { Challenge } from '@/types/challenge' + +const router = useRouter() + +const oneWeekFromNow = new Date() +oneWeekFromNow.setDate(oneWeekFromNow.getDate() + 7) +const minDate = oneWeekFromNow.toISOString().slice(0, 16) + +const thirtyDaysFromNow = new Date() +thirtyDaysFromNow.setDate(thirtyDaysFromNow.getDate() + 30) +const maxDate = thirtyDaysFromNow.toISOString().slice(0, 16) + +const challengeInstance = ref<Challenge>({ + title: '', + perPurchase: 20, + saved: 0, + target: 100, + description: '', + due: minDate + ':00.000Z', + type: '' +}) + +const isAmountSaved = ref(false) +const timesSaved = ref(challengeInstance.value.saved / challengeInstance.value.perPurchase) + +watch( + () => timesSaved.value, + (newVal) => { + challengeInstance.value.saved = newVal * challengeInstance.value.perPurchase + challengeInstance.value.saved = parseFloat(challengeInstance.value.saved.toFixed(2)) + } +) + +watch( + () => challengeInstance.value.saved, + (newVal) => { + challengeInstance.value.saved = Math.max( + 0, + Math.min(challengeInstance.value.target, newVal) + ) + challengeInstance.value.saved = parseFloat(challengeInstance.value.saved.toFixed(2)) + timesSaved.value = challengeInstance.value.saved / challengeInstance.value.perPurchase + timesSaved.value = parseFloat(timesSaved.value.toFixed(2)) + } +) + +watch( + () => challengeInstance.value.perPurchase, + (newVal) => { + challengeInstance.value.perPurchase = Math.max( + 1, + Math.min(challengeInstance.value.target, newVal) + ) + challengeInstance.value.perPurchase = parseFloat( + challengeInstance.value.perPurchase.toFixed(2) + ) + timesSaved.value = challengeInstance.value.saved / challengeInstance.value.perPurchase + timesSaved.value = parseFloat(timesSaved.value.toFixed(2)) + } +) + +watch( + () => challengeInstance.value.target, + (newVal) => { + challengeInstance.value.target = Math.max( + Math.max(challengeInstance.value.saved, 1), + newVal + ) + } +) + +const selectedDate = ref(minDate) +watch( + () => selectedDate.value, + (newVal) => { + if (newVal) { + selectedDate.value = newVal < minDate ? minDate : newVal + challengeInstance.value.due = selectedDate.value + ':00.000Z' + } + } +) + +const isEdit = computed(() => router.currentRoute.value.name === 'edit-challenge') +const pageTitle = computed(() => (isEdit.value ? 'Rediger utfordring' : 'Ny utfordring')) +const submitButton = computed(() => (isEdit.value ? 'Oppdater' : 'Opprett')) +const completion = computed( + () => (challengeInstance.value.saved / challengeInstance.value.target) * 100 +) + +const isInputValid = computed(() => { + return ( + challengeInstance.value.title !== '' && + challengeInstance.value.target > 0 && + challengeInstance.value.due !== '' + ) +}) + +const submitAction = () => { + if (!isInputValid.value) { + return () => alert('Fyll ut alle feltene') + } + + if (isEdit.value) { + updateChallenge() + } else { + createChallenge() + } +} + +onMounted(async () => { + if (isEdit.value) { + const challengeId = router.currentRoute.value.params.id + if (!challengeId) return router.push({ name: 'challenges' }) + + await authInterceptor(`/users/me/challenges/${challengeId}`) + .then((response) => { + challengeInstance.value = response.data + selectedDate.value = response.data.due.slice(0, 16) + }) + .catch((error) => { + console.error(error) + router.push({ name: 'challenges' }) + }) + } +}) + +const createChallenge = () => { + authInterceptor + .post('/users/me/challenges', challengeInstance.value, {}) + .then(() => { + return router.push({ name: 'challenges' }) + }) + .catch((error) => { + console.error(error) + }) +} + +const updateChallenge = () => { + authInterceptor + .put(`/users/me/challenges/${challengeInstance.value.id}`, challengeInstance.value) + .then(() => { + router.push({ name: 'challenges' }) + }) + .catch((error) => { + console.error(error) + }) +} + +const deleteChallenge = () => { + authInterceptor + .delete(`/users/me/challenges/${challengeInstance.value.id}`) + .then(() => { + router.push({ name: 'challenges' }) + }) + .catch((error) => { + console.error(error) + }) +} +</script> + +<template> + <div class="flex flex-col justify-center items-center"> + <h1 class="font-bold" v-text="pageTitle" /> + <div class="flex flex-col gap-5 items-center justify-center"> + <div class="flex flex-col"> + <p class="mx-4">Tittel*</p> + <input + v-model="challengeInstance.title" + placeholder="Skriv en tittel" + type="text" + /> + </div> + + <div class="flex flex-col"> + <p class="mx-4">Type</p> + <input v-model="challengeInstance.type" placeholder="Skriv en type" type="text" /> + </div> + + <div class="flex flex-col"> + <p class="mx-4">Beskrivelse</p> + <textarea + v-model="challengeInstance.description" + class="w-80 h-20 no-rezise" + placeholder="Beskriv sparemÃ¥let" + /> + </div> + + <div class="flex flex-col sm:flex-row gap-3"> + <div class="flex flex-col"> + <p class="mx-4">Pris per sparing</p> + <input + v-model="challengeInstance.perPurchase" + class="w-40 text-right" + placeholder="Kr spart per sparing" + type="number" + /> + </div> + + <div class="flex flex-col"> + <div class="flex flex-row justify-between mx-4"> + <p>{{ isAmountSaved ? 'Kroner spart' : 'Antall sparinger' }}</p> + <button class="p-0 bg-transparent" @click="isAmountSaved = !isAmountSaved"> + ðŸ”„ï¸ + </button> + </div> + <input + v-if="isAmountSaved" + v-model="challengeInstance.saved" + class="w-40 text-right" + min="0" + placeholder="Sparebeløp" + type="number" + /> + <input + v-else + v-model="timesSaved" + class="w-40 text-right" + placeholder="Kr spart per sparing" + type="number" + /> + </div> + + <div class="flex flex-col"> + <p class="mx-4">Av mÃ¥lbeløp...*</p> + <input + v-model="challengeInstance.target" + class="w-40 text-right" + placeholder="MÃ¥lbeløp" + type="number" + /> + </div> + </div> + <ProgressBar :completion="completion" /> + + <div class="flex flex-col"> + <p class="mx-4">Forfallsdato*</p> + <input + v-model="selectedDate" + :max="maxDate" + :min="minDate" + placeholder="Forfallsdato" + type="datetime-local" + /> + </div> + + <div class="flex flex-row justify-between w-full"> + <button :disabled="!isInputValid" @click="submitAction" v-text="submitButton" /> + <button + v-if="isEdit" + class="ml-2 bg-button-danger" + @click="deleteChallenge" + v-text="'Slett'" + /> + <button + v-else + class="ml-2 bg-button-other" + @click="router.push({ name: 'challenges' })" + v-text="'Avbryt'" + /> + </div> + </div> + </div> +</template> + +<style scoped> +.no-rezise { + resize: none; +} +</style> diff --git a/src/views/ManageGoalView.vue b/src/views/ManageGoalView.vue new file mode 100644 index 0000000000000000000000000000000000000000..360cf137ae3a52657f0d319f5c1b5c88168a56b1 --- /dev/null +++ b/src/views/ManageGoalView.vue @@ -0,0 +1,213 @@ +<script lang="ts" setup> +import { useRouter } from 'vue-router' +import { computed, onMounted, ref, watch } from 'vue' +import type { Goal } from '@/types/goal' +import ProgressBar from '@/components/ProgressBar.vue' +import authInterceptor from '@/services/authInterceptor' + +const router = useRouter() + +const oneWeekFromNow = new Date() +oneWeekFromNow.setDate(oneWeekFromNow.getDate() + 7) +const minDate = oneWeekFromNow.toISOString().slice(0, 16) + +const thirtyDaysFromNow = new Date() +thirtyDaysFromNow.setDate(thirtyDaysFromNow.getDate() + 30) +const maxDate = thirtyDaysFromNow.toISOString().slice(0, 16) + +const goalInstance = ref<Goal>({ + id: 0, + title: '', + saved: 50, + target: 100, + completion: 0, + description: '', + priority: 0, + createdOn: undefined, + due: minDate + ':00.000Z', + completedOn: null +}) + +watch( + () => goalInstance.value.saved, + (newVal) => { + goalInstance.value.saved = Math.max(0, Math.min(goalInstance.value.target, newVal)) + } +) + +watch( + () => goalInstance.value.target, + (newVal) => { + goalInstance.value.target = Math.max(Math.max(goalInstance.value.saved, 1), newVal) + } +) + +const selectedDate = ref(minDate) +watch( + () => selectedDate.value, + (newVal) => { + if (newVal) { + selectedDate.value = newVal < minDate ? minDate : newVal + goalInstance.value.due = selectedDate.value + ':00.000Z' + } + console.log(selectedDate.value) + } +) + +const isEdit = computed(() => router.currentRoute.value.name === 'edit-goal') +const pageTitle = computed(() => (isEdit.value ? 'Rediger sparemÃ¥l' : 'Nytt sparemÃ¥l')) +const submitButton = computed(() => (isEdit.value ? 'Oppdater' : 'Opprett')) +const completion = computed(() => (goalInstance.value.saved / goalInstance.value.target) * 100) + +const isInputValid = computed(() => { + return ( + goalInstance.value.title !== '' && + goalInstance.value.target > 0 && + goalInstance.value.due !== '' + ) +}) + +const submitAction = () => { + if ( + goalInstance.value.title === '' || + goalInstance.value.target < 1 || + goalInstance.value.due === '' + ) { + return () => alert('Fyll ut alle feltene') + } + + if (isEdit.value) { + updateGoal() + } else { + createGoal() + } +} + +onMounted(async () => { + if (isEdit.value) { + const goalId = router.currentRoute.value.params.id + if (!goalId) return router.push({ name: 'goals' }) + + await authInterceptor(`/users/me/goals/${goalId}`) + .then((response) => { + goalInstance.value = response.data + selectedDate.value = response.data.due.slice(0, 16) + }) + .catch((error) => { + console.error(error) + router.push({ name: 'goals' }) + }) + } +}) + +const createGoal = () => { + authInterceptor + .post('/users/me/goals', goalInstance.value, {}) + .then(() => { + return router.push({ name: 'goals' }) + }) + .catch((error) => { + console.error(error) + }) +} + +const updateGoal = () => { + authInterceptor + .put(`/users/me/goals/${goalInstance.value.id}`, goalInstance.value) + .then(() => { + router.push({ name: 'goals' }) + }) + .catch((error) => { + console.error(error) + }) +} + +const deleteGoal = () => { + authInterceptor + .delete(`/users/me/goals/${goalInstance.value.id}`) + .then(() => { + router.push({ name: 'goals' }) + }) + .catch((error) => { + console.error(error) + }) +} +</script> + +<template> + <div class="flex flex-col justify-center items-center"> + <h1 class="font-bold" v-text="pageTitle" /> + <div class="flex flex-col gap-5 items-center justify-center"> + <div class="flex flex-col"> + <p class="mx-4">Tittel*</p> + <input v-model="goalInstance.title" placeholder="Skriv en tittel" type="text" /> + </div> + + <div class="flex flex-col"> + <p class="mx-4">Beskrivelse</p> + <textarea + v-model="goalInstance.description" + class="w-80 h-20 no-rezise" + placeholder="Beskriv sparemÃ¥let" + /> + </div> + + <div class="flex flex-col sm:flex-row gap-3"> + <div class="flex flex-col"> + <p class="mx-4">Kroner spart...</p> + <input + v-model="goalInstance.saved" + class="w-40 text-right" + min="0" + placeholder="Sparebeløp" + type="number" + /> + </div> + + <div class="flex flex-col"> + <p class="mx-4">Av mÃ¥lbeløp...*</p> + <input + v-model="goalInstance.target" + class="w-40 text-right" + placeholder="MÃ¥lbeløp" + type="number" + /> + </div> + </div> + <ProgressBar :completion="completion" /> + + <div class="flex flex-col"> + <p class="mx-4">Forfallsdato*</p> + <input + v-model="selectedDate" + :max="maxDate" + :min="minDate" + placeholder="Forfallsdato" + type="datetime-local" + /> + </div> + + <div class="flex flex-row justify-between w-full"> + <button :disabled="!isInputValid" @click="submitAction" v-text="submitButton" /> + <button + v-if="isEdit" + class="ml-2 bg-button-danger" + @click="deleteGoal" + v-text="'Slett'" + /> + <button + v-else + class="ml-2 bg-button-other" + @click="router.push({ name: 'goals' })" + v-text="'Avbryt'" + /> + </div> + </div> + </div> +</template> + +<style scoped> +.no-rezise { + resize: none; +} +</style> diff --git a/src/views/UserChallengesView.vue b/src/views/UserChallengesView.vue new file mode 100644 index 0000000000000000000000000000000000000000..ffd2a3aac3a8d5e579653a4a3abf49847d271100 --- /dev/null +++ b/src/views/UserChallengesView.vue @@ -0,0 +1,56 @@ +<script lang="ts" setup> +import { useRouter } from 'vue-router' +import { onMounted, ref, watch } from 'vue' +import authInterceptor from '@/services/authInterceptor' +import draggable from 'vuedraggable' +import type { Challenge } from '@/types/challenge' +import CardChallenge from '@/components/CardChallenge.vue' + +const router = useRouter() + +const currentPage = ref(1) +const totalPages = ref(1) + +const challenges = ref<Challenge[]>([]) + +onMounted(async () => { + await authInterceptor('/users/me/challenges') + .then((response) => { + currentPage.value = response.data.currentPage + totalPages.value = response.data.totalPages + challenges.value = response.data.content + }) + .catch((error) => { + console.error(error) + }) +}) + +watch(challenges, (newChallenges) => { + console.log(newChallenges) +}) +</script> + +<template> + <h1 class="font-bold text-center">Dine utfordringer</h1> + <div class="flex flex-col gap-5 items-center"> + <draggable + v-model="challenges" + class="flex flex-col justify-center gap-10 sm:flex-row" + item-key="id" + > + <template #item="{ element, index }"> + <CardChallenge :key="index" :challenge-instance="element" /> + </template> + </draggable> + <div class="flex flex-row gap-5"> + <button @click="router.push({ name: 'new-challenge' })"> + Opprett en ny utfordring + </button> + <button @click="router.push({ name: 'edit-challenge', params: { id: 1 } })"> + Rediger rekkefølge + </button> + </div> + </div> +</template> + +<style scoped></style> diff --git a/src/views/UserGoalsView.vue b/src/views/UserGoalsView.vue new file mode 100644 index 0000000000000000000000000000000000000000..84f46d9c8d82f15055e0e121454df04941727b69 --- /dev/null +++ b/src/views/UserGoalsView.vue @@ -0,0 +1,55 @@ +<script lang="ts" setup> +import CardGoal from '@/components/CardGoal.vue' + +import { useRouter } from 'vue-router' +import { onMounted, ref, watch } from 'vue' +import authInterceptor from '@/services/authInterceptor' +import type { Goal } from '@/types/goal' +import draggable from 'vuedraggable' + +const router = useRouter() + +const currentPage = ref(1) +const totalPages = ref(1) + +const goals = ref<Goal[]>([]) + +onMounted(async () => { + await authInterceptor('/users/me/goals') + .then((response) => { + currentPage.value = response.data.currentPage + totalPages.value = response.data.totalPages + goals.value = response.data.content + }) + .catch((error) => { + console.error(error) + }) +}) + +watch(goals, (newGoals) => { + console.log(newGoals) +}) +</script> + +<template> + <h1 class="font-bold text-center">Dine sparemÃ¥l</h1> + <div class="flex flex-col gap-5 items-center"> + <draggable + v-model="goals" + class="flex flex-col justify-center gap-10 sm:flex-row" + item-key="id" + > + <template #item="{ element, index }"> + <CardGoal :key="index" :goal-instance="element" /> + </template> + </draggable> + <div class="flex flex-row gap-5"> + <button @click="router.push({ name: 'new-goal' })">Opprett et nytt sparemÃ¥l</button> + <button @click="router.push({ name: 'edit-goal', params: { id: 1 } })"> + Rediger rekkefølge + </button> + </div> + </div> +</template> + +<style scoped></style> diff --git a/tailwind.config.js b/tailwind.config.js index abe363ca68d682e6127eb145197317c59e9df11d..85dfc48accae773f41c89b06e72c48669c4df697 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,32 +1,34 @@ /** @type {import('tailwindcss').Config} */ export default { - content: [ - "./index.html", - "./src/**/*.{vue,js,ts,jsx,tsx}", - ], - theme: { - extend: { - animation: { - clouds: 'clouds 20s linear infinite', - beach: 'beach 5s linear infinite', - flame: 'flame 0.3s linear infinite', - }, - keyframes: { - clouds: { - '0%': { backgroundPosition: '0%' }, - '100%': { backgroundPosition: '-100%' }, - }, - beach: { - '0%': { backgroundPosition: '0%' }, - '100%': { backgroundPosition: '-100%' }, - }, - - flame: { - '0%, 100%': { transform: 'translateX(0%)' }, - '50%': { transform: 'translateX(50%)' }, - }, - } + content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'], + theme: { + extend: { + animation: { + clouds: 'clouds 20s linear infinite', + beach: 'beach 5s linear infinite', + flame: 'flame 0.3s linear infinite' + }, + keyframes: { + clouds: { + '0%': { backgroundPosition: '0%' }, + '100%': { backgroundPosition: '-100%' } + }, + beach: { + '0%': { backgroundPosition: '0%' }, + '100%': { backgroundPosition: '-100%' } + }, + + flame: { + '0%, 100%': { transform: 'translateX(0%)' }, + '50%': { transform: 'translateX(50%)' } + } + }, + colors: { + 'button-disabled': 'var(--grey)', + 'button-danger': 'var(--red)', + 'button-other': 'var(--accent1)' + } + } }, - }, - plugins: [], + plugins: [] } \ No newline at end of file