Merge branch 'main' of https://git.targo.ca/Targo/targo_frontend into release/nicolas/v1.2

This commit is contained in:
Nic D 2026-03-09 14:03:43 -04:00
commit c6187305d9
45 changed files with 1242 additions and 389 deletions

1
.env.production Normal file
View File

@ -0,0 +1 @@
VITE_TARGO_BACKEND_URL=PREFIX_BACKEND_URL

View File

@ -0,0 +1 @@
Workflows to be compliant with CI/CD pipelines

View File

@ -0,0 +1,150 @@
name: Node-CI
on:
push:
branches: [main]
paths-ignore:
- '**.md'
- 'docs/**'
pull_request:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
lint:
runs-on: ubuntu-24.04
steps:
- name: Gitea Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: '22'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run linter
run: npm run lint
- name: Notify Google Chat
if: ${{ failure() }} # Use always to ensure that the notification is also send on failure of former steps
uses: SimonScholz/google-chat-action@3b3519e5102dba8aa5046fd711c4b553586409bb # v1.1.0
with:
webhookUrl: '${{ secrets.GOOGLE_CHAT_WEBHOOK }}'
jobStatus: ${{ job.status }}
title: Lint failed
test:
runs-on: ubuntu-24.04
steps:
- name: Gitea Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: '22'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
- name: Notify Google Chat
if: ${{ failure() }} # Use always to ensure that the notification is also send on failure of former steps
uses: SimonScholz/google-chat-action@3b3519e5102dba8aa5046fd711c4b553586409bb # v1.1.0
with:
webhookUrl: '${{ secrets.GOOGLE_CHAT_WEBHOOK }}'
jobStatus: ${{ job.status }}
title: Test failed
build:
runs-on: ubuntu-24.04
steps:
- name: Gitea Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: '22'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run build
run: npm run build
- uses: actions/upload-artifact@v3
with:
name: dist
path: dist
retention-days: 1
- uses: actions/upload-artifact@v3
with:
name: source
path: src
retention-days: 1
- uses: actions/upload-artifact@v3
with:
name: Dockerfile
path: Dockerfile
retention-days: 1
- name: Notify Google Chat
if: ${{ failure() }} # Use always to ensure that the notification is also send on failure of former steps
uses: SimonScholz/google-chat-action@3b3519e5102dba8aa5046fd711c4b553586409bb # v1.1.0
with:
webhookUrl: '${{ secrets.GOOGLE_CHAT_WEBHOOK }}'
jobStatus: ${{ job.status }}
title: Build failed
deploy:
needs: [lint, test, build]
runs-on: ubuntu-24.04
#if: github.ref == 'refs/heads/main'
permissions:
contents: read
id-token: write
steps:
- name: Getting Dockerfile
uses: actions/download-artifact@v3
with:
name: Dockerfile
- name: Downloading dist folder
uses: actions/download-artifact@v3
with:
name: dist
path: dist
- name: Downloading src folder
uses: actions/download-artifact@v3
with:
name: source
path: source
- name: Set version number
run: echo "VERSION_NUMBER=$(date +'%y%m%d.%H%M%S')" >> $GITHUB_ENV
- name: Building docker image
run: docker build -t git.targo.ca/targo/targo-frontend-staging:2.${{ env.VERSION_NUMBER }} .
- name: Add tag Latest
run: docker tag git.targo.ca/targo/targo-frontend-staging:2.${{ env.VERSION_NUMBER }} git.targo.ca/targo/targo-frontend-staging:latest
- name: Push images to server
run: |
docker login -u ${{ secrets.CI_USER }} -p ${{ secrets.CI_PASSWORD }} git.targo.ca
docker push git.targo.ca/targo/targo-frontend-staging:2.${{ env.VERSION_NUMBER }}
docker push git.targo.ca/targo/targo-frontend-staging:latest
curl --location 'https://n8napi.targo.ca/webhook/portainer' --header 'Authorization: Basic ${{ secrets.API_SECRET}}' --form 'stack="new_targo_app_staging"'

View File

@ -1,20 +1,15 @@
# Step 1 - Building the app
FROM node:22 AS build
WORKDIR /app
COPY package*.json ./
# Set environment variables
ARG BACKEND_URL
ENV VITE_TARGO_BACKEND_URL=$BACKEND_URL
# Install dependencies
RUN npm install -g @quasar/cli
COPY . .
RUN npm install
RUN npm run build
# Step 2 - Move Applicatin to Nginx
FROM nginx:alpine FROM nginx:alpine
COPY --from=build /app/dist/spa /usr/share/nginx/html
#Copy files
RUN mkdir /usr/share/nginx/html/src RUN mkdir /usr/share/nginx/html/src
COPY --from=build /app/src /usr/share/nginx/html/src COPY dist/spa /usr/share/nginx/html
COPY source /usr/share/nginx/html/src
#Add script to replace VITE env vars
RUN curl -o /docker-entrypoint.d/env.sh -O https://raw.githubusercontent.com/Dutchskull/Vite-Dynamic-Environment-Variables/refs/heads/main/app/env.sh
RUN dos2unix /docker-entrypoint.d/env.sh
RUN chmod +x /docker-entrypoint.d/env.sh
ENTRYPOINT ["/docker-entrypoint.sh"]
EXPOSE 80 EXPOSE 80
CMD ["nginx", "-g", "daemon off;"] CMD ["nginx", "-g", "daemon off;"]

35
env.sh Normal file
View File

@ -0,0 +1,35 @@
#!/usr/bin/env sh
# ================================================================================
# File: env.sh
# Description: Replaces environment variables in asset files.
# Usage: Run this script in your terminal, ensuring APP_PREFIX and ASSET_DIRS are set.
# ================================================================================
# Set the exit flag to exit immediately if any command fails
set -e
# Check if APP_PREFIX is set
: "${APP_PREFIX:?APP_PREFIX must be set (e.g. APP_PREFIX='APP_PREFIX_')}"
# Check if ASSET_DIRS is set
: "${ASSET_DIR:?Must set ASSET_DIR to one path}"
# Check if the directory exists
if [ ! -d "$ASSET_DIR" ]; then
# If not, display a warning message and skip to the next iteration
echo "Warning: directory '$ASSET_DIR' not found, skipping."
continue
fi
# Display the current directory being scanned
echo "Scanning directory: $ASSET_DIR"
# Iterate through each environment variable that starts with APP_PREFIX
env | grep "^${APP_PREFIX}" | while IFS='=' read -r key value; do
# Display the variable being replaced
echo " • Replacing ${key}${value}"
# Use find and sed to replace the variable in all files within the directory
find "$ASSET_DIR" -type f \
-exec sed -i "s|${key}|${value}|g" {} +
done

View File

@ -41,7 +41,7 @@ export default defineConfig((ctx) => {
build: { build: {
target: { target: {
browser: [ 'es2022', 'firefox115', 'chrome115', 'safari14' ], browser: [ 'es2022', 'firefox115', 'chrome115', 'safari14' ],
node: 'node20' node: 'node20',
}, },
typescript: { typescript: {
@ -66,7 +66,11 @@ export default defineConfig((ctx) => {
// polyfillModulePreload: true, // polyfillModulePreload: true,
// distDir // distDir
// extendViteConf (viteConf) {}, extendViteConf: (_config) => ({
optimizeDeps: {
exclude: ['tesseract.js']
}
}),
// viteVuePluginOptions: {}, // viteVuePluginOptions: {},
vitePlugins: [ vitePlugins: [

View File

@ -16,7 +16,8 @@ declare module 'vue' {
// for each client) // for each client)
const api = axios.create({ const api = axios.create({
baseURL: import.meta.env.VITE_TARGO_BACKEND_URL, baseURL: import.meta.env.VITE_TARGO_BACKEND_URL,
withCredentials: true withCredentials: true,
timeout: 5 * 60 * 1000,
}); });
export default defineBoot(({ app }) => { export default defineBoot(({ app }) => {

View File

@ -5,8 +5,11 @@ export default {
chat_placeholder: "Enter a Message", chat_placeholder: "Enter a Message",
chat_thinking: "Thinking...", chat_thinking: "Thinking...",
error: { error: {
NO_REPLY_RECEIVED: "encountered an error while waiting for chatbot to reply", NO_REPLY_RECEIVED: "FRONTEND ERROR: the backend did not include a message text in the response",
SEND_MESSAGE_FAILED: "unable to send message to chatbot", NO_DATA_RECEIVED: "BACKEND ERROR: the backend did not receive any data from the AI agent",
NO_OUTPUT_RECEIVED: "BACKEND ERROR: the backend received data, but no output, from the AI agent",
SEND_MESSAGE_FAILED: "FRONTEND ERROR: Your message could not send or was timed out",
GENERIC_RESPONSE_ERROR: "BACKEND ERROR: An unexpected error occured while waiting for the backend to respond",
}, },
}, },
@ -245,6 +248,8 @@ export default {
day: "day", day: "day",
empty: "empty", empty: "empty",
name: "name", name: "name",
lock: "",
unlock: "",
}, },
misc: { misc: {
or: "or", or: "or",
@ -290,6 +295,8 @@ export default {
apply_preset_day: "Apply schedule to day", apply_preset_day: "Apply schedule to day",
apply_preset_week: "Apply schedule to week", apply_preset_week: "Apply schedule to week",
save_successful: "timesheets saved", save_successful: "timesheets saved",
unsaved_changes_title: "You have unsaved changes",
unsaved_changes_caption: "Save before leaving?",
nav_button: { nav_button: {
calendar_date_picker: "Calendar", calendar_date_picker: "Calendar",
current_week: "This week", current_week: "This week",
@ -331,6 +338,8 @@ export default {
empty_list: 'No registered expenses', empty_list: 'No registered expenses',
employee_comment: 'Comment', employee_comment: 'Comment',
supervisor_comment: 'Supervisor note', supervisor_comment: 'Supervisor note',
no_attachment: "no image attached",
temp_attachment_msg: "attachments are temporarily down, you can omit them from submissions and forward them to the finance department",
actions: { actions: {
delete_confirm: "Delete this expense?", delete_confirm: "Delete this expense?",
}, },
@ -347,9 +356,10 @@ export default {
type: "Type", type: "Type",
types: { types: {
PER_DIEM: "Per Diem", PER_DIEM: "Per Diem",
EXPENSES: "expense", EXPENSES: "reimbursement",
MILEAGE: "mileage", MILEAGE: "mileage",
ON_CALL: "on-call allowance", ON_CALL: "on-call allowance",
COMMISSION: "Commission",
}, },
}, },
errors: { errors: {

View File

@ -5,8 +5,11 @@ export default {
chat_placeholder: "Entrez un Message", chat_placeholder: "Entrez un Message",
chat_thinking: "Réflexion en cours...", chat_thinking: "Réflexion en cours...",
error: { error: {
NO_REPLY_RECEIVED: "Une erreur est survenu lors de la réception de la réponse du chatbot", NO_REPLY_RECEIVED: "ERREUR FRONTEND: Le frontend n'a pas recu de message de l'IA dans la réponse du backend",
SEND_MESSAGE_FAILED: "Une erreur est survenu lors de l'envoi de votre message", NO_DATA_RECEIVED: "ERREUR BACKEND: L'agent IA n'a pas inclu de data dans sa réponse au backend",
NO_OUTPUT_RECEIVED: "ERREUR BACKEND: L'agent IA a inclu du data, mais pas de output, dans sa réponse au backend",
SEND_MESSAGE_FAILED: "ERREUR FRONTEND: Une erreur est survenue lors de l'envoi de votre message, ou la connexion a été perdu",
GENERIC_RESPONSE_ERROR: "ERREUR BACKEND: Une erreur est survenu lors de la réception de la réponse du AI",
}, },
}, },
@ -245,6 +248,8 @@ export default {
day: "jour", day: "jour",
empty: "vide", empty: "vide",
name: "nom", name: "nom",
lock: "verrouiller",
unlock: "déverrouiller",
}, },
misc: { misc: {
or: "ou", or: "ou",
@ -290,6 +295,8 @@ export default {
apply_preset_day: "Appliquer horaire pour la journée", apply_preset_day: "Appliquer horaire pour la journée",
apply_preset_week: "Appliquer horaire pour la semaine", apply_preset_week: "Appliquer horaire pour la semaine",
save_successful: "feuilles de temps enregistrées", save_successful: "feuilles de temps enregistrées",
unsaved_changes_title: "Vous avez des changements non-enregistrés",
unsaved_changes_caption: "Sauvegardez avant de quitter?",
nav_button: { nav_button: {
calendar_date_picker: "Calendrier", calendar_date_picker: "Calendrier",
current_week: "Semaine actuelle", current_week: "Semaine actuelle",
@ -331,6 +338,8 @@ export default {
empty_list: 'Aucun dépense enregistrée', empty_list: 'Aucun dépense enregistrée',
employee_comment: 'Commentaire', employee_comment: 'Commentaire',
supervisor_comment: 'Note du Superviseur', supervisor_comment: 'Note du Superviseur',
no_attachment: "aucune pièce jointe",
temp_attachment_msg: "Les pièces jointes sont désactivés temporairement, vous pouvez laisser le champ vide et acheminez vos recus au département de la comptabilité",
actions: { actions: {
delete_confirm: "Supprimer cette dépense?", delete_confirm: "Supprimer cette dépense?",
}, },
@ -347,9 +356,10 @@ export default {
type: "Type", type: "Type",
types: { types: {
PER_DIEM: "Per diem", PER_DIEM: "Per diem",
EXPENSES: "dépense", EXPENSES: "remboursement",
MILEAGE: "kilométrage", MILEAGE: "kilométrage",
ON_CALL: "Prime de garde", ON_CALL: "Prime de garde",
COMMISSION: "Commission",
}, },
}, },
errors: { errors: {

View File

@ -2,9 +2,11 @@
lang="ts" lang="ts"
setup setup
> >
import { useUiStore } from 'src/stores/ui-store'; import { useAuthStore } from 'src/stores/auth-store';
import { useUiStore } from 'src/stores/ui-store';
const uiStore = useUiStore(); const uiStore = useUiStore();
const authStore = useAuthStore();
</script> </script>
<template> <template>
@ -31,6 +33,26 @@
/> />
</q-btn> </q-btn>
</q-toolbar-title> </q-toolbar-title>
<q-icon
name="las la-user-circle"
size="md"
class="q-px-sm"
/>
<div v-if="$q.platform.is.mobile" class="text-uppercase text-bold text-h4 text-accent">
{{ authStore.user?.first_name.charAt(0) }}{{ authStore.user?.last_name.charAt(0) }}
</div>
<div v-if="!$q.platform.is.mobile" class="row items-end">
<div class="text-uppercase text-h4 text-weight-medium text-accent q-px-xs">
{{ authStore.user?.first_name }}
</div>
<div class="text-uppercase text-h6 text-weight-light q-pr-md">
{{ authStore.user?.last_name }}
</div>
</div>
</q-toolbar> </q-toolbar>
</q-header> </q-header>
</template> </template>

View File

@ -9,6 +9,7 @@
import { useUiStore } from 'src/stores/ui-store'; import { useUiStore } from 'src/stores/ui-store';
import { RouteNames } from 'src/router/router-constants'; import { RouteNames } from 'src/router/router-constants';
import { ModuleNames, type UserModuleAccess } from 'src/modules/shared/models/user.models'; import { ModuleNames, type UserModuleAccess } from 'src/modules/shared/models/user.models';
import { useAuthApi } from 'src/modules/auth/composables/use-auth-api';
const DRAWER_BUTTONS: { i18n_key: string, icon: string, route: RouteNames, required_module?: UserModuleAccess }[] = [ const DRAWER_BUTTONS: { i18n_key: string, icon: string, route: RouteNames, required_module?: UserModuleAccess }[] = [
{ i18n_key: 'nav_bar.home', icon: "home", route: RouteNames.DASHBOARD, required_module: ModuleNames.DASHBOARD }, { i18n_key: 'nav_bar.home', icon: "home", route: RouteNames.DASHBOARD, required_module: ModuleNames.DASHBOARD },
@ -21,6 +22,7 @@
const q = useQuasar(); const q = useQuasar();
const auth_store = useAuthStore(); const auth_store = useAuthStore();
const authApi = useAuthApi();
const ui_store = useUiStore(); const ui_store = useUiStore();
const router = useRouter(); const router = useRouter();
const is_mini = ref(true); const is_mini = ref(true);
@ -33,12 +35,8 @@
}); });
}; };
const handleLogout = () => { const handleLogout = async () => {
auth_store.logout(); await authApi.logout();
router.push({ name: 'login' }).catch(err => {
console.error('could not log you out: ', err);
})
}; };
onMounted(() => { onMounted(() => {

View File

@ -12,6 +12,15 @@
// const is_remembered = ref<boolean>(false); // const is_remembered = ref<boolean>(false);
const is_employee_email = computed(() => email.value.includes('@targ')); const is_employee_email = computed(() => email.value.includes('@targ'));
const is_game_time = computed(() => email.value.includes('allumette')); const is_game_time = computed(() => email.value.includes('allumette'));
const onSubmitConnectionRequest = () => {
console.log('submit requested');
if (is_employee_email.value) return;
}
const onClickEmployeeConnect = () => {
auth_api.oidcLogin();
}
</script> </script>
<template> <template>
@ -27,28 +36,31 @@
/> />
</div> </div>
<div class="q-pt-sm q-px-xl q-pb-lg "> <div class="q-px-xl q-pb-lg ">
<q-card-section class="text-center text-uppercase"> <div class="text-h6 text-weight-bold text-center text-uppercase q-py-sm">
<div class="text-h6 text-weight-bold"> {{ $t('login.page_header') }}
{{ $t('login.page_header') }} </div>
</div>
</q-card-section>
<q-form @submit="auth_api.login"> <q-form
@submit="onSubmitConnectionRequest"
autocomplete="on"
>
<q-input <q-input
v-model="email" v-model="email"
dense dense
outlined outlined
label-slot
name="email"
color="accent" color="accent"
label-color="accent" label-color="accent"
class="rounded-5 inset-shadow bg-white" class="rounded-5 inset-shadow bg-white"
label-slot
input-class="text-h6 text-primary" input-class="text-h6 text-primary"
> >
<template #label> <template #label>
<span class="text-weight-bolder text-uppercase text-overline"> {{ $t('login.email') }} </span> <span class="text-weight-bolder text-uppercase text-overline"> {{ $t('login.email') }} </span>
</template> </template>
</q-input> </q-input>
<!-- Stay-logged-in section, removed temporarly until customer module is up --> <!-- Stay-logged-in section, removed temporarly until customer module is up -->
<!-- <q-card-section <!-- <q-card-section
horizontal horizontal
@ -86,58 +98,59 @@
<!-- <q-card-section class="text-center q-pa-none q-mt-none"> <!-- <q-card-section class="text-center q-pa-none q-mt-none">
<RouterLink disabled class="text-primary" to="/signup">{{ $t('loginPage.signUp') }}</RouterLink> <RouterLink disabled class="text-primary" to="/signup">{{ $t('loginPage.signUp') }}</RouterLink>
</q-card-section> --> </q-card-section> -->
<q-card-section class="row q-pt-sm">
<q-separator
size="2px"
color="accent"
class="col self-center"
/>
<span class="col text-accent text-weight-bolder text-center text-uppercase self-center">{{
$t('shared.misc.or') }}</span>
<q-separator
size="2px"
color="accent"
class="col self-center"
/>
</q-card-section>
<q-card-section class="column q-px-sm q-pt-none">
<q-btn
rounded
push
disabled
color="blue-grey-7"
icon="img:src/assets/Facebook-f_Logo-White-Logo.wine.svg"
:label="$t('login.button.facebook')"
class="full-width row q-mb-sm"
>
<q-tooltip
anchor="top middle"
class="bg-primary"
>{{ $t('login.tooltip.coming_soon') }}</q-tooltip>
</q-btn>
<q-slide-transition>
<div v-if="is_employee_email">
<transition
slow
enter-active-class="animated zoomIn"
leave-active-class="animated zoomOut"
>
<q-btn
push
rounded
type="submit"
color="accent"
icon="img:src/assets/logo-targo-simple.svg"
:label="$t('login.button.employee')"
class="full-width row"
@click="onClickEmployeeConnect"
/>
</transition>
</div>
</q-slide-transition>
</q-card-section>
</q-form> </q-form>
<q-card-section class="row q-pt-sm">
<q-separator
size="2px"
color="accent"
class="col self-center"
/>
<span class="col text-accent text-weight-bolder text-center text-uppercase self-center">{{
$t('shared.misc.or') }}</span>
<q-separator
size="2px"
color="accent"
class="col self-center"
/>
</q-card-section>
<q-card-section class="column q-px-sm q-pt-none">
<q-btn
rounded
push
disabled
color="blue-grey-7"
icon="img:src/assets/Facebook-f_Logo-White-Logo.wine.svg"
:label="$t('login.button.facebook')"
class="full-width row q-mb-sm"
>
<q-tooltip
anchor="top middle"
class="bg-primary"
>{{ $t('login.tooltip.coming_soon') }}</q-tooltip>
</q-btn>
<q-slide-transition>
<div v-if="is_employee_email">
<transition
slow
enter-active-class="animated zoomIn"
leave-active-class="animated zoomOut"
>
<q-btn
push
rounded
color="accent"
icon="img:src/assets/logo-targo-simple.svg"
:label="$t('login.button.employee')"
class="full-width row"
@click="auth_api.oidcLogin"
/>
</transition>
</div>
</q-slide-transition>
</q-card-section>
</div> </div>
</q-card> </q-card>
<div v-if="is_game_time"> <div v-if="is_game_time">

View File

@ -11,8 +11,10 @@ export const useAuthApi = () => {
authStore.oidcLogin(); authStore.oidcLogin();
}; };
const logout = () => { const logout = async () => {
authStore.logout(); authStore.user = undefined;
await authStore.logout();
window.location.href = "https://auth.targo.ca/application/o/montargo/end-session/";
}; };
return { return {

View File

@ -8,9 +8,9 @@ export const AuthService = {
//TODO: OIDC customer sign-in, eventually //TODO: OIDC customer sign-in, eventually
}, },
logout: () => { logout: async () => {
// TODO: logout logic // TODO: logout logic
api.post('/auth/logout') await api.post('/auth/logout');
}, },
refreshToken: () => { refreshToken: () => {

View File

@ -2,49 +2,33 @@
setup setup
lang="ts" lang="ts"
> >
import MarkdownIt from 'markdown-it';
import { computed } from 'vue'; import { computed } from 'vue';
// import MarkdownIt from 'markdown-it' import { useI18n } from 'vue-i18n';
import { useAuthStore } from 'src/stores/auth-store'; import { useAuthStore } from 'src/stores/auth-store';
import type { Message } from 'src/modules/chatbot/models/dialogue-message.model'; import type { Message } from 'src/modules/chatbot/models/dialogue-message.model';
import { useI18n } from 'vue-i18n';
const { t } = useI18n(); // ========== state ========================================
const auth_store = useAuthStore();
const { message } = defineProps<{ const { message } = defineProps<{
message: Message; message: Message;
}>(); }>();
const message_text = computed(() => message.text.includes('chatbot.') ? t(message.text) : message.text) const { t } = useI18n();
const authStore = useAuthStore();
const md = new MarkdownIt({
linkify: true,
typographer: true,
})
const is_error = computed(() => message.text.includes('NO_REPLY_RECEIVED') || message.text.includes('SEND_MESSAGE_FAILED')); // ========== computed ========================================
// Initialize Markdown parser const message_text = computed(() => message.text.includes('chatbot.') ?
// const md = new MarkdownIt({ t(message.text) :
// // breaks: false, // Support line breaks message.text.replaceAll("\\n", "\n")
// linkify: true, // Make URLs clickable );
// html: false // Prevent raw HTML injection const is_error = computed(() => message.text.includes('chatbot.error.'));
// })
// // Removes all the h1,h2,h3 to make the Md more user friendly
// const cleanMarkdown = (markdown: string): string => {
// if (!markdown) return ''
// return markdown
// .replace(/\r\n/g, '\n') // normalize Windows line endings
// .replace(/([.!?])(\s+)(?=[A-Z])/g, '$1\n') // insert line break after sentence-ending punctuation
// .replace(/\n{3,}/g, '\n\n') // squash triple+ line breaks
// .replace(/^#{1,3}\s(.*)$/gm, '#### $1') // downgrade headings
// }
// // Compute parsed content
// const parsedText = computed((): string => {
// const cleaned = cleanMarkdown(message.text || '')
// if (cleaned.includes('chatbot.')) {
// const translated_message = t(message.text);
// return md.render(translated_message);
// }
// return md.render(cleaned);
// })
</script> </script>
<template> <template>
@ -67,11 +51,12 @@
<q-chat-message <q-chat-message
v-else v-else
text-html
:sent="message.sent" :sent="message.sent"
:name="message.sent ? auth_store.user?.first_name : 'TargoBot'" :name="message.sent ? authStore.user?.first_name : 'TargoBot'"
:bg-color="message.sent ? 'accent' : 'info'" :bg-color="message.sent ? 'accent' : 'info'"
:text-color="message.sent ? 'white' : ''" :text-color="message.sent ? 'white' : ''"
:text="[message_text]" :text="[md.renderInline(message_text)]"
/> />
</template> </template>

View File

@ -10,26 +10,42 @@
import DetailsDialogChartExpenses from 'src/modules/timesheet-approval/components/details-dialog-chart-expenses.vue'; import DetailsDialogChartExpenses from 'src/modules/timesheet-approval/components/details-dialog-chart-expenses.vue';
import TimesheetWrapper from 'src/modules/timesheets/components/timesheet-wrapper.vue'; import TimesheetWrapper from 'src/modules/timesheets/components/timesheet-wrapper.vue';
import ExpenseDialogList from 'src/modules/timesheets/components/expense-dialog-list.vue'; import ExpenseDialogList from 'src/modules/timesheets/components/expense-dialog-list.vue';
import ExpenseDialogForm from 'src/modules/timesheets/components/expense-dialog-form.vue';
import { useTimesheetApprovalApi } from '../composables/use-timesheet-approval-api'; import { useTimesheetApprovalApi } from '../composables/use-timesheet-approval-api';
import { useShiftApi } from 'src/modules/timesheets/composables/use-shift-api';
import { useExpensesStore } from 'src/stores/expense-store';
import { Expense } from 'src/modules/timesheets/models/expense.models';
import { date } from 'quasar';
// ========== state ========================================
const { t } = useI18n(); const { t } = useI18n();
const timesheet_store = useTimesheetStore(); const timesheetStore = useTimesheetStore();
const expenseStore = useExpensesStore();
const timesheetApprovalApi = useTimesheetApprovalApi(); const timesheetApprovalApi = useTimesheetApprovalApi();
const is_dialog_open = ref(false); const shiftApi = useShiftApi();
const isDialogOpen = ref(false);
// ========== computed ========================================
const isApproved = computed(() => timesheetStore.timesheets.every(timesheet => timesheet.is_approved));
const isApproved = computed(() => timesheet_store.timesheets.every(timesheet => timesheet.is_approved));
const approveButtonLabel = computed(() => isApproved.value ? const approveButtonLabel = computed(() => isApproved.value ?
t('timesheet_approvals.table.verified') : t('shared.label.unlock') :
t('timesheet_approvals.table.unverified') t('shared.label.lock')
); );
const approveButtonIcon = computed(() => isApproved.value ? 'lock' : 'lock_open');
const hasExpenses = computed(() => timesheet_store.timesheets.some(timesheet => const approveButtonIcon = computed(() => isApproved.value ? 'las la-lock' : 'las la-unlock');
const hasExpenses = computed(() => timesheetStore.timesheets.some(timesheet =>
Object.values(timesheet.weekly_expenses).some(hours => hours > 0)) Object.values(timesheet.weekly_expenses).some(hours => hours > 0))
); );
// ========== methods ========================================
const onClickApproveAll = async () => { const onClickApproveAll = async () => {
const employeeEmail = timesheet_store.current_pay_period_overview?.email; const employeeEmail = timesheetStore.current_pay_period_overview?.email;
const isApproved = timesheet_store.timesheets.every(timesheet => timesheet.is_approved); const isApproved = timesheetStore.timesheets.every(timesheet => timesheet.is_approved);
if (employeeEmail !== undefined && isApproved !== undefined) { if (employeeEmail !== undefined && isApproved !== undefined) {
await timesheetApprovalApi.toggleTimesheetsApprovalByEmployeeEmail( await timesheetApprovalApi.toggleTimesheetsApprovalByEmployeeEmail(
@ -38,19 +54,32 @@
); );
} }
} }
const onClickSaveTimesheets = async () => {
await shiftApi.saveShiftChanges(timesheetStore.current_pay_period_overview?.email);
expenseStore.is_showing_create_form = false;
}
const onClickExpenseCreate = () => {
expenseStore.mode = 'create';
if (timesheetStore.pay_period)
expenseStore.current_expense = new Expense(timesheetStore.pay_period.period_start);
else
expenseStore.current_expense = new Expense(date.formatDate(new Date(), 'YYYY-MM-DD'));
}
</script> </script>
<template> <template>
<q-dialog <q-dialog
v-model="timesheet_store.is_details_dialog_open" v-model="timesheetStore.is_details_dialog_open"
full-width full-width
full-height full-height
transition-show="jump-down" transition-show="jump-down"
transition-hide="jump-down" transition-hide="jump-down"
backdrop-filter="blur(6px)" backdrop-filter="blur(6px)"
@show="is_dialog_open = true" @show="isDialogOpen = true"
@hide="is_dialog_open = false" @hide="isDialogOpen = false"
@before-hide="timesheet_store.getTimesheetOverviews" @before-hide="timesheetStore.getTimesheetOverviews"
> >
<div <div
class="column bg-secondary hide-scrollbar shadow-12 rounded-15 q-pb-sm no-wrap" class="column bg-secondary hide-scrollbar shadow-12 rounded-15 q-pb-sm no-wrap"
@ -58,13 +87,14 @@
> >
<!-- employee name --> <!-- employee name -->
<div class="col-auto row flex-center q-px-none q-py-sm sticky-top bg-secondary full-width shadow-4"> <div class="col-auto row flex-center q-px-none q-py-sm sticky-top bg-secondary full-width shadow-4">
<span class="col text-h4 text-weight-bolder text-uppercase q-px-lg"> <span class="col-auto text-h4 text-weight-bolder text-uppercase q-px-lg">
{{ timesheet_store.selected_employee_name }} {{ timesheetStore.selected_employee_name }}
</span> </span>
<div class="col-auto q-px-lg"> <div class="col-auto q-px-lg">
<q-btn <q-btn
push :push="isApproved"
:outline="!isApproved"
dense dense
size="lg" size="lg"
color="accent" color="accent"
@ -84,11 +114,28 @@
</transition> </transition>
</q-btn> </q-btn>
</div> </div>
<q-space />
<div class="col-auto q-px-md">
<q-btn
push
dense
size="lg"
:disable="timesheetStore.is_loading || !timesheetStore.canSaveTimesheets"
:color="timesheetStore.is_loading || !timesheetStore.canSaveTimesheets ? 'grey-5' : 'accent'"
icon="upload"
:label="$t('shared.label.save')"
:class="$q.platform.is.mobile && ($q.screen.width < $q.screen.height) ? 'full-width' : 'q-ml-md'"
@click="onClickSaveTimesheets"
/>
</div>
</div> </div>
<!-- employee pay period details using chart --> <!-- employee pay period details using chart -->
<div <div
v-if="is_dialog_open" v-if="isDialogOpen"
:key="hasExpenses ? 0 : 1"
class="col-auto q-px-md no-wrap" class="col-auto q-px-md no-wrap"
:class="$q.platform.is.mobile ? 'column' : 'row'" :class="$q.platform.is.mobile ? 'column' : 'row'"
> >
@ -100,15 +147,54 @@
/> />
</div> </div>
<div class="col-auto"> <div class="col-auto column">
<q-separator
spaced
size="4px"
class="q-mx-md"
/>
<ExpenseDialogList mode="approval" /> <ExpenseDialogList mode="approval" />
<q-expansion-item
v-if="!isApproved"
v-model="expenseStore.is_showing_create_form"
hide-expand-icon
:dense="!$q.platform.is.mobile"
group="expenses"
@show="onClickExpenseCreate()"
header-class="bg-accent text-white q-mx-md rounded-5"
>
<template #header>
<div class="row items-center">
<q-icon
name="add_circle_outline"
size="md"
class="col-auto"
:class="expenseStore.is_showing_create_form ? 'invisible' : ''"
/>
<span class="col-auto text-uppercase text-weight-bold text-h6 q-ml-xs q-mr-sm">
{{ $t('timesheet.expense.add_expense') }}
</span>
</div>
</template>
<ExpenseDialogForm :email="timesheetStore.current_pay_period_overview?.email"/>
</q-expansion-item>
<q-separator
spaced
size="4px"
class="q-mx-md"
/>
</div> </div>
<!-- list of shifts --> <!-- list of shifts -->
<div class="col-auto"> <div class="col-auto q-mt-md">
<TimesheetWrapper <TimesheetWrapper
mode="approval" mode="approval"
:employee-email="timesheet_store.current_pay_period_overview?.email" :employee-email="timesheetStore.current_pay_period_overview?.email"
class="col-auto" class="col-auto"
/> />
</div> </div>

View File

@ -16,6 +16,7 @@
import { type OverviewColumns, pay_period_overview_columns, PayPeriodOverviewFilters, type TimesheetApprovalOverview } from 'src/modules/timesheet-approval/models/timesheet-overview.models'; import { type OverviewColumns, pay_period_overview_columns, PayPeriodOverviewFilters, type TimesheetApprovalOverview } from 'src/modules/timesheet-approval/models/timesheet-overview.models';
import { getHoursMinutesStringFromHoursFloat } from 'src/utils/date-and-time-utils'; import { getHoursMinutesStringFromHoursFloat } from 'src/utils/date-and-time-utils';
import { useUiStore } from 'src/stores/ui-store'; import { useUiStore } from 'src/stores/ui-store';
import { useTimesheetApi } from 'src/modules/timesheets/composables/use-timesheet-api';
// ========== constants ======================================== // ========== constants ========================================
@ -46,11 +47,12 @@
const q = useQuasar(); const q = useQuasar();
const uiStore = useUiStore(); const uiStore = useUiStore();
const auth_store = useAuthStore(); const authStore = useAuthStore();
const timesheet_store = useTimesheetStore(); const timesheetApi = useTimesheetApi();
const timesheet_approval_api = useTimesheetApprovalApi(); const timesheetStore = useTimesheetStore();
const timesheetApprovalApi = useTimesheetApprovalApi();
const overview_filters = ref<PayPeriodOverviewFilters>({ const overviewFilters = ref<PayPeriodOverviewFilters>({
is_showing_inactive: false, is_showing_inactive: false,
is_showing_team_only: true, is_showing_team_only: true,
supervisors: [], supervisors: [],
@ -63,21 +65,21 @@
uiStore.user_preferences.is_timesheet_approval_grid uiStore.user_preferences.is_timesheet_approval_grid
); );
const overview_rows = computed(() => const overviewRows = computed(() =>
timesheet_store.pay_period_overviews.filter(overview => overview) timesheetStore.pay_period_overviews.filter(overview => overview)
); );
// ========== methods ======================================== // ========== methods ========================================
const onClickedDetails = async (row: TimesheetApprovalOverview) => { const onClickedDetails = async (row: TimesheetApprovalOverview) => {
timesheet_store.current_pay_period_overview = row; timesheetStore.current_pay_period_overview = row;
await timesheet_store.getTimesheetsByOptionalEmployeeEmail(row.email); await timesheetApi.getTimesheetsByCurrentPayPeriod(row.email);
timesheet_store.is_details_dialog_open = true; timesheetStore.is_details_dialog_open = true;
}; };
const onClickApproveAll = async (email: string, is_approved: boolean) => { const onClickApproveAll = async (email: string, is_approved: boolean) => {
await timesheet_approval_api.toggleTimesheetsApprovalByEmployeeEmail(email, is_approved); await timesheetApprovalApi.toggleTimesheetsApprovalByEmployeeEmail(email, is_approved);
} }
const filterEmployeeRows = (rows: readonly TimesheetApprovalOverview[], terms: PayPeriodOverviewFilters): TimesheetApprovalOverview[] => { const filterEmployeeRows = (rows: readonly TimesheetApprovalOverview[], terms: PayPeriodOverviewFilters): TimesheetApprovalOverview[] => {
@ -88,7 +90,7 @@
} }
if (terms.is_showing_team_only) { if (terms.is_showing_team_only) {
result = result.filter(row => row.supervisor !== null && row.supervisor.email === (auth_store.user ? auth_store.user.email : '')); result = result.filter(row => row.supervisor !== null && row.supervisor.email === (authStore.user ? authStore.user.email : ''));
} }
if (terms.name_search_string.length > 0) { if (terms.name_search_string.length > 0) {
@ -114,20 +116,20 @@
<template> <template>
<div class="full-width"> <div class="full-width">
<LoadingOverlay v-model="timesheet_store.is_loading" /> <LoadingOverlay v-model="timesheetStore.is_loading" />
<q-table <q-table
dense dense
row-key="email" row-key="email"
color="accent" color="accent"
separator="none" separator="none"
hide-pagination hide-pagination
:rows="overview_rows" :rows="overviewRows"
:columns="pay_period_overview_columns" :columns="pay_period_overview_columns"
:table-colspan="pay_period_overview_columns.length" :table-colspan="pay_period_overview_columns.length"
:visible-columns="VISIBLE_COLUMNS" :visible-columns="VISIBLE_COLUMNS"
:grid="isGridMode" :grid="isGridMode"
:pagination="{ sortBy: 'is_active' }" :pagination="{ sortBy: 'is_active' }"
:filter="overview_filters" :filter="overviewFilters"
:filter-method="filterEmployeeRows" :filter-method="filterEmployeeRows"
:rows-per-page-options="[0]" :rows-per-page-options="[0]"
class="bg-transparent" class="bg-transparent"
@ -138,20 +140,20 @@
:no-results-label="$t('shared.error.no_search_results')" :no-results-label="$t('shared.error.no_search_results')"
:loading-label="$t('shared.label.loading')" :loading-label="$t('shared.label.loading')"
table-header-style="min-width: 80xp; max-width: 80px;" table-header-style="min-width: 80xp; max-width: 80px;"
:style="overview_rows.length > 0 ? `max-height: ${maxHeight}px;` : ''" :style="overviewRows.length > 0 ? `max-height: ${maxHeight}px;` : ''"
:table-style="{ tableLayout: 'fixed' }" :table-style="{ tableLayout: 'fixed' }"
@row-click="(_evt, row: TimesheetApprovalOverview) => onClickedDetails(row)" @row-click="(_evt, row: TimesheetApprovalOverview) => onClickedDetails(row)"
> >
<template #top> <template #top>
<OverviewListTopMobile <OverviewListTopMobile
v-if="$q.platform.is.mobile" v-if="$q.platform.is.mobile"
v-model:filters="overview_filters" v-model:filters="overviewFilters"
v-model:visible-columns="VISIBLE_COLUMNS" v-model:visible-columns="VISIBLE_COLUMNS"
/> />
<OverviewListTop <OverviewListTop
v-else v-else
v-model:filters="overview_filters" v-model:filters="overviewFilters"
v-model:visible-columns="VISIBLE_COLUMNS" v-model:visible-columns="VISIBLE_COLUMNS"
/> />
</template> </template>
@ -192,7 +194,7 @@
mode="out-in" mode="out-in"
> >
<div <div
:key="props.rowIndex + (timesheet_store.pay_period?.pay_period_no ?? 0)" :key="props.rowIndex + (timesheetStore.pay_period?.pay_period_no ?? 0)"
class="rounded-5" class="rounded-5"
:style="`animation-delay: ${props.rowIndex / 15}s; opacity: ${props.row.is_active ? '1' : '0.5'};`" :style="`animation-delay: ${props.rowIndex / 15}s; opacity: ${props.row.is_active ? '1' : '0.5'};`"
> >
@ -265,7 +267,7 @@
<template #item="props: { row: TimesheetApprovalOverview, rowIndex: number }"> <template #item="props: { row: TimesheetApprovalOverview, rowIndex: number }">
<OverviewListItem <OverviewListItem
v-model="props.row.is_approved" v-model="props.row.is_approved"
:key="props.row.email + timesheet_store.pay_period?.pay_period_no" :key="props.row.email + timesheetStore.pay_period?.pay_period_no"
:row="props.row" :row="props.row"
@click-details="onClickedDetails" @click-details="onClickedDetails"
@click-approval-all="is_approved => onClickApproveAll(props.row.email, is_approved)" @click-approval-all="is_approved => onClickApproveAll(props.row.email, is_approved)"
@ -275,7 +277,7 @@
<!-- Template for custome failed-to-load state --> <!-- Template for custome failed-to-load state -->
<template #no-data="{ message, filter }"> <template #no-data="{ message, filter }">
<div <div
v-if="!timesheet_store.is_loading" v-if="!timesheetStore.is_loading"
class="full-width column items-center text-accent" class="full-width column items-center text-accent"
> >
<q-icon <q-icon

View File

@ -0,0 +1,45 @@
<script
setup
lang="ts"
>
import { ref } from 'vue';
import { useExpensesStore } from 'src/stores/expense-store';
const expenseStore = useExpensesStore();
const isMaximized = ref(false);
const imageDimension = ref('400px');
const onClickMaximizeButton = () => {
isMaximized.value = !isMaximized.value;
imageDimension.value = isMaximized.value ? '100%' : '400px';
}
</script>
<template>
<q-dialog
v-model="expenseStore.isShowingAttachmentDialog"
backdrop-filter="blur(4px)"
:full-height="isMaximized"
:full-width="isMaximized"
>
<q-card class="q-pa-md flex-center relative-position">
<q-img
spinner-color="accent"
fit="contain"
:height="imageDimension"
:width="imageDimension"
:src="expenseStore.attachmentURL"
/>
<q-btn
dense
size="lg"
color="accent"
:icon="isMaximized ? 'zoom_in_map' : 'zoom_out_map'"
class="absolute-bottom-right q-ma-sm rounded-5"
style="opacity: 0.5;"
@click="onClickMaximizeButton"
/>
</q-card>
</q-dialog>
</template>

View File

@ -20,6 +20,10 @@
const expense = defineModel<Expense>({ default: new Expense(new Date().toISOString().slice(0, 10)) }) const expense = defineModel<Expense>({ default: new Expense(new Date().toISOString().slice(0, 10)) })
const file = defineModel<File>('file'); const file = defineModel<File>('file');
const {email} = defineProps<{
email?: string | undefined;
}>();
const { t } = useI18n(); const { t } = useI18n();
const ui_store = useUiStore(); const ui_store = useUiStore();
const timesheet_store = useTimesheetStore(); const timesheet_store = useTimesheetStore();
@ -60,8 +64,17 @@
const requestExpenseCreationOrUpdate = async () => { const requestExpenseCreationOrUpdate = async () => {
if (file.value) if (file.value)
await expenses_api.upsertExpense(expenses_store.current_expense, file.value, employeeEmail ?? auth_store.user?.email ?? 'MISSING_EMAIL'); await expenses_api.upsertExpense(
expenses_store.current_expense,
email ?? employeeEmail ?? auth_store.user?.email ?? 'MISSING_EMAIL',
file.value
);
else
await expenses_api.upsertExpense(
expenses_store.current_expense,
email ?? employeeEmail ?? auth_store.user?.email ?? 'MISSING_EMAIL'
);
expenses_store.is_showing_create_form = true; expenses_store.is_showing_create_form = true;
expenses_store.mode = 'create'; expenses_store.mode = 'create';
expenses_store.current_expense = new Expense(date.formatDate(new Date(), 'YYYY-MM-DD')); expenses_store.current_expense = new Expense(date.formatDate(new Date(), 'YYYY-MM-DD'));
@ -258,6 +271,8 @@
dense dense
stack-label stack-label
label-slot label-slot
type="file"
accept="image/*"
> >
<template #prepend> <template #prepend>
<q-icon <q-icon

View File

@ -24,6 +24,7 @@
<template> <template>
<div class="column items-center q-pa-none"> <div class="column items-center q-pa-none">
<!-- title bar with close button -->
<div class="col row full-width bg-primary"> <div class="col row full-width bg-primary">
<q-item-label class="col text-h6 text-weight-bolder text-uppercase text-white q-py-sm q-px-md"> <q-item-label class="col text-h6 text-weight-bolder text-uppercase text-white q-py-sm q-px-md">
{{ $t('timesheet.expense.title') }} {{ $t('timesheet.expense.title') }}
@ -41,6 +42,19 @@
/> />
</div> </div>
<!-- REMOVE: ONCE ATTACHMENTS ARE FULLY IMPLEMENTED -->
<div class="col-auto row bg-warning flex-center q-px-md q-py-xs full-width">
<q-icon
name="las la-exclamation-triangle"
size="md"
class="q-px-md"
/>
<span class="text-bold text-center">
{{ $t('timesheet.expense.temp_attachment_msg') }}
</span>
</div>
<div class="col row flex-center full-width q-pt-sm q-px-md"> <div class="col row flex-center full-width q-pt-sm q-px-md">
<div class="col-auto row items-center q-px-md"> <div class="col-auto row items-center q-px-md">
<span <span
@ -80,7 +94,7 @@
> >
{{ weekly_totals.mileage.toFixed(1) }} {{ weekly_totals.mileage.toFixed(1) }}
</span> </span>
<q-icon <q-icon
v-if="$q.screen.lt.md" v-if="$q.screen.lt.md"
name="commute" name="commute"

View File

@ -4,7 +4,7 @@
> >
import ExpenseDialogForm from 'src/modules/timesheets/components/expense-dialog-form.vue'; import ExpenseDialogForm from 'src/modules/timesheets/components/expense-dialog-form.vue';
import { ref } from 'vue'; import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { date, Notify } from 'quasar'; import { date, Notify } from 'quasar';
import { unwrapAndClone } from 'src/utils/unwrap-and-clone'; import { unwrapAndClone } from 'src/utils/unwrap-and-clone';
@ -21,13 +21,19 @@
const { mode = 'normal' } = defineProps<{ const { mode = 'normal' } = defineProps<{
mode?: 'approval' | 'normal'; mode?: 'approval' | 'normal';
}>(); }>();
const { t } = useI18n(); const { t } = useI18n();
const expenses_api = useExpensesApi(); const expenses_api = useExpensesApi();
const expenses_store = useExpensesStore(); const expenses_store = useExpensesStore();
const timesheet_store = useTimesheetStore(); const timesheet_store = useTimesheetStore();
const is_showing_update_form = ref(false); const is_showing_update_form = ref(false);
// ========== computed ===================================
const attachmentButtonColor = computed(() => expense.value.attachment_name ?
(expense.value.is_approved ? 'white' : 'accent') :
'grey-5');
// ===================== methods ========================= // ===================== methods =========================
const requestExpenseDeletion = async () => { const requestExpenseDeletion = async () => {
@ -46,7 +52,11 @@
expenses_store.current_expense = unwrapAndClone(expense.value); expenses_store.current_expense = unwrapAndClone(expense.value);
expenses_store.current_expense.is_approved = !expenses_store.current_expense.is_approved; expenses_store.current_expense.is_approved = !expenses_store.current_expense.is_approved;
const success = await expenses_store.upsertExpense(expenses_store.current_expense, timesheet_store.current_pay_period_overview?.email); const success = await expenses_store.upsertExpense(
expenses_store.current_expense,
timesheet_store.current_pay_period_overview?.email
);
if (success) { if (success) {
expense.value.is_approved = !expense.value.is_approved; expense.value.is_approved = !expense.value.is_approved;
} else { } else {
@ -57,6 +67,12 @@
}); });
} }
} }
const onClickAttachment = async () => {
expenses_store.isShowingAttachmentDialog = true;
await expenses_store.getAttachmentURL(expense.value.attachment_key);
console.log('image url: ', expenses_store.attachmentURL);
}
</script> </script>
<template> <template>
@ -75,7 +91,6 @@
<div class="col-auto"> <div class="col-auto">
<q-icon <q-icon
:name="getExpenseIcon(expense.type)" :name="getExpenseIcon(expense.type)"
:color="expense.is_approved ? 'white' : ($q.dark.isActive ? 'white' : 'blue-grey-8')"
size="lg" size="lg"
class="q-pr-md" class="q-pr-md"
/> />
@ -117,14 +132,25 @@
<div class="col row items-center justify-start"> <div class="col row items-center justify-start">
<q-btn <q-btn
push push
:color="expense.is_approved ? 'white' : 'accent'" :disable="expense.attachment_name === undefined"
:color="attachmentButtonColor"
:text-color="expense.is_approved ? 'accent' : 'white'" :text-color="expense.is_approved ? 'accent' : 'white'"
class="col-auto q-px-sm q-mr-sm" class="col-auto q-px-sm q-mr-sm"
icon="attach_file" icon="attach_file"
@click.stop="onClickAttachment"
/> />
<q-item-label class="col"> <q-item-label class="col">
attachment_name.jpg <span v-if="expense.attachment_name">
{{ expense.attachment_name }}
</span>
<span
v-else
class="text-italic text-blue-grey-5 text-uppercase"
>
{{ $t('timesheet.expense.no_attachment') }}
</span>
</q-item-label> </q-item-label>
</div> </div>

View File

@ -6,6 +6,7 @@
import ExpenseDialogForm from 'src/modules/timesheets/components/expense-dialog-form.vue'; import ExpenseDialogForm from 'src/modules/timesheets/components/expense-dialog-form.vue';
import ExpenseDialogHeader from 'src/modules/timesheets/components/expense-dialog-header.vue'; import ExpenseDialogHeader from 'src/modules/timesheets/components/expense-dialog-header.vue';
import ExpenseDialogFormMobile from 'src/modules/timesheets/components/mobile/expense-dialog-form-mobile.vue'; import ExpenseDialogFormMobile from 'src/modules/timesheets/components/mobile/expense-dialog-form-mobile.vue';
import ExpenseDialogAttachmentViewer from 'src/modules/timesheets/components/expense-dialog-attachment-viewer.vue';
import { date } from 'quasar'; import { date } from 'quasar';
import { useExpensesStore } from 'src/stores/expense-store'; import { useExpensesStore } from 'src/stores/expense-store';
@ -32,6 +33,8 @@
transition-show="jump-down" transition-show="jump-down"
transition-hide="jump-down" transition-hide="jump-down"
> >
<ExpenseDialogAttachmentViewer class="z-top" />
<q-card <q-card
class="q-pa-none rounded-10 shadow-24 bg-secondary" class="q-pa-none rounded-10 shadow-24 bg-secondary"
style=" min-width: 70vw;" style=" min-width: 70vw;"

View File

@ -58,10 +58,16 @@
if (file.value) if (file.value)
await expenses_api.upsertExpense( await expenses_api.upsertExpense(
expenses_store.current_expense, expenses_store.current_expense,
file.value, employeeEmail ?? auth_store.user?.email ?? 'MISSING_EMAIL' employeeEmail ?? auth_store.user?.email ?? 'MISSING_EMAIL',
file.value
); );
else
emit('onUpdateClicked'); await expenses_api.upsertExpense(
expenses_store.current_expense,
employeeEmail ?? auth_store.user?.email ?? 'MISSING_EMAIL'
);
emit('onUpdateClicked');
}; };
onMounted(() => { onMounted(() => {

View File

@ -134,7 +134,6 @@
<!-- avatar type icon section --> <!-- avatar type icon section -->
<q-icon <q-icon
:name="getExpenseIcon(expense.type)" :name="getExpenseIcon(expense.type)"
:color="expense.is_approved ? 'white' : ($q.dark.isActive ? 'white' : 'primary')"
size="lg" size="lg"
class="col-auto q-pr-sm" class="col-auto q-pr-sm"
/> />

View File

@ -5,25 +5,31 @@
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { QSelect, QInput } from 'quasar'; import { QSelect, QInput } from 'quasar';
import { useUiStore } from 'src/stores/ui-store'; import { useUiStore } from 'src/stores/ui-store';
import { SHIFT_OPTIONS } from 'src/modules/timesheets/utils/shift.util'; import { getCurrentDailyMinutesWorked, getTimeStringFromMinutes, SHIFT_OPTIONS } from 'src/modules/timesheets/utils/shift.util';
import type { Shift } from 'src/modules/timesheets/models/shift.models'; import type { Shift, ShiftOption, ShiftType } from 'src/modules/timesheets/models/shift.models';
import { getHoursMinutesStringFromHoursFloat } from 'src/utils/date-and-time-utils';
const ui_store = useUiStore(); // ========== state ========================================
const SHIFT_TYPES_WITH_PREDEFINED_TIMES: ShiftType[] = ['HOLIDAY', 'SICK', 'VACATION'];
const COMMENT_LENGTH_MAX = 280; const COMMENT_LENGTH_MAX = 280;
const shift = defineModel<Shift>('shift', { required: true }); const shift = defineModel<Shift>('shift', { required: true });
const shift_type_selected = ref(SHIFT_OPTIONS.find(option => option.value == shift.value.type));
const select_ref = ref<QSelect | null>(null);
const is_showing_comment_popup = ref(false);
const comment_length = computed(() => shift.value.comment?.length ?? 0);
const error_message = ref('');
const { errorMessage = undefined, dense = false, hasShiftAfter = false, isTimesheetApproved = false } = defineProps<{ const {
dense = false,
hasShiftAfter = false,
isTimesheetApproved = false,
errorMessage = undefined,
expectedDailyHours = 8,
currentShifts,
} = defineProps<{
dense?: boolean; dense?: boolean;
hasShiftAfter?: boolean; hasShiftAfter?: boolean;
isTimesheetApproved?: boolean; isTimesheetApproved?: boolean;
errorMessage?: string | undefined; errorMessage?: string | undefined;
expectedDailyHours?: number;
currentShifts: Shift[];
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@ -31,8 +37,23 @@
'onTimeFieldBlur': [void]; 'onTimeFieldBlur': [void];
}>(); }>();
const ui_store = useUiStore();
const shiftTypeSelected = ref(SHIFT_OPTIONS.find(option => option.value == shift.value.type));
const select_ref = ref<QSelect | null>(null);
const is_showing_comment_popup = ref(false);
const error_message = ref('');
const isShowingPredefinedTime = ref(shift.value.type === 'HOLIDAY');
const predefinedHoursString = ref('');
const predefinedHoursBgColor = ref(`bg-${shiftTypeSelected.value?.icon_color ?? ''}`);
// ========== computed ========================================
const comment_length = computed(() => shift.value.comment?.length ?? 0);
// ========== methods =========================================
const onBlurShiftTypeSelect = () => { const onBlurShiftTypeSelect = () => {
if (shift_type_selected.value === undefined) { if (shiftTypeSelected.value === undefined) {
shift.value.type = 'REGULAR'; shift.value.type = 'REGULAR';
shift.value.id = 0; shift.value.id = 0;
emit('requestDelete'); emit('requestDelete');
@ -56,11 +77,35 @@
return 'negative'; return 'negative';
}; };
const onShiftTypeChange = (option: ShiftOption) => {
shift.value.type = option.value;
if (SHIFT_TYPES_WITH_PREDEFINED_TIMES.includes(option.value)) {
predefinedHoursBgColor.value = `bg-${option.icon_color}`;
shift.value.start_time = '00:00';
if (option.value === 'SICK' || option.value === 'VACATION') {
const workedMinutes = getCurrentDailyMinutesWorked(currentShifts);
console.log('worked minutes: ', workedMinutes);
const expectedWorkedMinutes = expectedDailyHours * 60;
const leftOverMinutes = expectedWorkedMinutes - workedMinutes;
shift.value.end_time = getTimeStringFromMinutes(leftOverMinutes);
isShowingPredefinedTime.value = false;
} else {
isShowingPredefinedTime.value = true;
predefinedHoursString.value = getHoursMinutesStringFromHoursFloat(expectedDailyHours);
shift.value.end_time = getTimeStringFromMinutes(expectedDailyHours * 60);
}
} else
isShowingPredefinedTime.value = false;
}
onMounted(() => { onMounted(() => {
if (ui_store.focus_next_component) { if (ui_store.focus_next_component) {
select_ref.value?.focus(); select_ref.value?.focus();
select_ref.value?.showPopup(); select_ref.value?.showPopup();
shift_type_selected.value = undefined; shiftTypeSelected.value = undefined;
ui_store.focus_next_component = false; ui_store.focus_next_component = false;
} }
@ -124,7 +169,7 @@
<!-- shift type --> <!-- shift type -->
<q-select <q-select
ref="select" ref="select"
v-model="shift_type_selected" v-model="shiftTypeSelected"
:standout="$q.dark.isActive ? 'bg-blue-grey-3' : 'bg-blue-grey-9'" :standout="$q.dark.isActive ? 'bg-blue-grey-3' : 'bg-blue-grey-9'"
dense dense
:borderless="(shift.is_approved && isTimesheetApproved)" :borderless="(shift.is_approved && isTimesheetApproved)"
@ -141,7 +186,7 @@
:style="shift.is_approved ? 'background-color: #0a7d32 !important;' : ''" :style="shift.is_approved ? 'background-color: #0a7d32 !important;' : ''"
popup-content-style="border: 2px solid var(--q-accent)" popup-content-style="border: 2px solid var(--q-accent)"
@blur="onBlurShiftTypeSelect" @blur="onBlurShiftTypeSelect"
@update:model-value="option => shift.type = option.value" @update:model-value="onShiftTypeChange"
> >
<template #selected-item="scope"> <template #selected-item="scope">
<div <div
@ -227,7 +272,25 @@
</q-select> </q-select>
</div> </div>
<div class="col row items-start text-uppercase rounded-5 q-pa-xs"> <div
v-if="isShowingPredefinedTime"
class="col row items-start text-uppercase rounded-5 q-pa-xs relative-position"
>
<div
class="absolute-full rounded-5 q-mx-sm q-my-xs"
:class="predefinedHoursBgColor"
style="opacity: 0.3;"
></div>
<span class="col text-center text-uppercase text-h6 text-bold q-py-xs">
{{ getHoursMinutesStringFromHoursFloat(expectedDailyHours) }}
</span>
</div>
<div
v-else
class="col row items-start text-uppercase rounded-5 q-pa-xs"
>
<!-- punch in field --> <!-- punch in field -->
<div class="col q-pr-xs"> <div class="col q-pr-xs">
<q-input <q-input

View File

@ -6,15 +6,29 @@
import { computed, inject, onMounted, ref } from 'vue'; import { computed, inject, onMounted, ref } from 'vue';
import { QSelect, QInput, useQuasar, type QSelectProps, QPopupProxy } from 'quasar'; import { QSelect, QInput, useQuasar, type QSelectProps, QPopupProxy } from 'quasar';
import { useUiStore } from 'src/stores/ui-store'; import { useUiStore } from 'src/stores/ui-store';
import { SHIFT_OPTIONS } from 'src/modules/timesheets/utils/shift.util';
import type { Shift } from 'src/modules/timesheets/models/shift.models';
import { useAuthStore } from 'src/stores/auth-store'; import { useAuthStore } from 'src/stores/auth-store';
import { getCurrentDailyMinutesWorked, getShiftOptions, getTimeStringFromMinutes, SHIFT_OPTIONS } from 'src/modules/timesheets/utils/shift.util';
import type { Shift, ShiftOption, ShiftType } from 'src/modules/timesheets/models/shift.models';
import { getHoursMinutesStringFromHoursFloat } from 'src/utils/date-and-time-utils';
// ================== State ================== // ========== Constants ========================================
const SHIFT_TYPES_WITH_PREDEFINED_TIMES: ShiftType[] = ['HOLIDAY', 'SICK', 'VACATION'];
const COMMENT_LENGTH_MAX = 280;
// ========== State ========================================
const shift = defineModel<Shift>('shift', { required: true }); const shift = defineModel<Shift>('shift', { required: true });
const { errorMessage = undefined, isTimesheetApproved = false, holiday = false } = defineProps<{ const {
errorMessage = undefined,
isTimesheetApproved = false,
currentShifts,
holiday = false,
expectedDailyHours = 8,
} = defineProps<{
currentShifts: Shift[];
expectedDailyHours?: number;
isTimesheetApproved?: boolean; isTimesheetApproved?: boolean;
errorMessage?: string | undefined; errorMessage?: string | undefined;
holiday?: boolean | undefined; holiday?: boolean | undefined;
@ -25,9 +39,6 @@
'onTimeFieldBlur': [void]; 'onTimeFieldBlur': [void];
}>(); }>();
const COMMENT_LENGTH_MAX = 280;
const q = useQuasar(); const q = useQuasar();
const { t } = useI18n(); const { t } = useI18n();
const ui_store = useUiStore(); const ui_store = useUiStore();
@ -39,10 +50,15 @@
const selectRef = ref<QSelect | null>(null); const selectRef = ref<QSelect | null>(null);
const shiftErrorMessage = ref<string | undefined>(); const shiftErrorMessage = ref<string | undefined>();
const is_showing_delete_confirm = ref(false); const is_showing_delete_confirm = ref(false);
const isShowingPredefinedTime = ref(shift.value.type === 'HOLIDAY');
const popupProxyRef = ref<QPopupProxy | null>(null); const popupProxyRef = ref<QPopupProxy | null>(null);
const predefinedHoursString = ref('');
const predefinedHoursBgColor = ref(`bg-${shiftTypeSelected.value?.icon_color ?? ''}`);
// ================== Computed ================== // ================== Computed ==================
const hasPTO = computed(() => currentShifts.some(shift => SHIFT_TYPES_WITH_PREDEFINED_TIMES.includes(shift.type)));
const rightClickMenuIcon = computed(() => shift.value.is_approved ? 'lock_open' : 'lock'); const rightClickMenuIcon = computed(() => shift.value.is_approved ? 'lock_open' : 'lock');
const rightClickMenuLabel = computed(() => shift.value.is_approved ? const rightClickMenuLabel = computed(() => shift.value.is_approved ?
@ -77,7 +93,7 @@
menuOffset: [0, 10], menuOffset: [0, 10],
menuAnchor: "bottom middle", menuAnchor: "bottom middle",
menuSelf: "top middle", menuSelf: "top middle",
options: SHIFT_OPTIONS, options: getShiftOptions(hasPTO.value, currentShifts.length > 1),
class: `col rounded-5 q-mx-xs bg-dark ${!shift.value.is_approved && !isTimesheetApproved ? '' : 'inset-shadow'}`, class: `col rounded-5 q-mx-xs bg-dark ${!shift.value.is_approved && !isTimesheetApproved ? '' : 'inset-shadow'}`,
popupContentClass: "text-uppercase text-weight-bold text-center rounded-5", popupContentClass: "text-uppercase text-weight-bold text-center rounded-5",
style: shift.value.is_approved ? (holiday ? 'background-color: #7b1fa2 !important' : 'background-color: #0a7d32 !important;') : '', style: shift.value.is_approved ? (holiday ? 'background-color: #7b1fa2 !important' : 'background-color: #0a7d32 !important;') : '',
@ -128,6 +144,30 @@
popupProxyRef.value.hide(); popupProxyRef.value.hide();
} }
const onShiftTypeChange = (option: ShiftOption) => {
shift.value.type = option.value;
if (SHIFT_TYPES_WITH_PREDEFINED_TIMES.includes(option.value)) {
predefinedHoursBgColor.value = `bg-${option.icon_color}`;
shift.value.start_time = '00:00';
if (option.value === 'SICK' || option.value === 'VACATION') {
const workedMinutes = getCurrentDailyMinutesWorked(currentShifts);
console.log('worked minutes: ', workedMinutes);
const expectedWorkedMinutes = expectedDailyHours * 60;
const leftOverMinutes = expectedWorkedMinutes - workedMinutes;
shift.value.end_time = getTimeStringFromMinutes(leftOverMinutes);
} else {
isShowingPredefinedTime.value = true;
predefinedHoursString.value = getHoursMinutesStringFromHoursFloat(expectedDailyHours);
shift.value.start_time = '00:00';
shift.value.end_time = `${expectedDailyHours}:00`;
}
} else
isShowingPredefinedTime.value = false;
}
onMounted(() => { onMounted(() => {
if (ui_store.focus_next_component) { if (ui_store.focus_next_component) {
selectRef.value?.focus(); selectRef.value?.focus();
@ -171,7 +211,7 @@
backdrop-filter="blur(6px)" backdrop-filter="blur(6px)"
> >
<div <div
class="column flex-center q-pa-md bg-secondary shadow-24 rounded-10" class="column flex-center q-pa-md bg-dark shadow-24 rounded-10"
style="border: 2px solid var(--q-accent);" style="border: 2px solid var(--q-accent);"
> >
<span class="col-auto text-h6 text-bold text-uppercase">{{ <span class="col-auto text-h6 text-bold text-uppercase">{{
@ -206,7 +246,7 @@
v-model="shiftTypeSelected" v-model="shiftTypeSelected"
v-bind="shiftTypeSelectProps" v-bind="shiftTypeSelectProps"
@blur="onBlurShiftTypeSelect" @blur="onBlurShiftTypeSelect"
@update:model-value="option => shift.type = option.value" @update:model-value="onShiftTypeChange"
> >
<template #selected-item="scope"> <template #selected-item="scope">
<div <div
@ -291,40 +331,58 @@
</q-select> </q-select>
</div> </div>
<div class="col row items-start text-uppercase rounded-5 q-pa-xs"> <div class="col row">
<!-- punch in field --> <!-- If shift type has predefined timestamps -->
<q-input <div
ref="start_time" v-if="isShowingPredefinedTime"
v-model="shift.start_time" class="col row q-pa-xs relative-position flex-center"
v-bind="timeInputProps"
type="time"
@blur="onTimeFieldBlur(shift.start_time)"
> >
<template #label> <div
<span class="absolute-full rounded-5 q-mx-sm q-my-xs"
class="text-weight-bolder" :class="predefinedHoursBgColor"
:class="shift.is_approved ? ' q-ml-md' : ''" style="opacity: 0.3;"
style="font-size: 0.95em;" ></div>
>{{ $t('shared.misc.in') }}</span>
</template>
</q-input>
<!-- punch out field --> <span class="col text-center text-uppercase text-h6 text-bold q-py-xs">
<q-input {{ getHoursMinutesStringFromHoursFloat(expectedDailyHours) }}
ref="end_time" </span>
v-model="shift.end_time" </div>
v-bind="timeInputProps"
type="time" <!-- Else show input fields for in-out timestamps -->
@blur="onTimeFieldBlur(shift.end_time)" <div v-else class="col row items-start text-uppercase rounded-5 q-pa-xs">
> <q-input
<template #label> ref="start_time"
<span v-model="shift.start_time"
class="text-weight-bolder" v-bind="timeInputProps"
:class="shift.is_approved ? ' q-ml-md' : ''" type="time"
style="font-size: 0.95em;" @blur="onTimeFieldBlur(shift.start_time)"
>{{ $t('shared.misc.out') }}</span> >
</template> <template #label>
</q-input> <span
class="text-weight-bolder"
:class="shift.is_approved ? ' q-ml-md' : ''"
style="font-size: 0.95em;"
>{{ $t('shared.misc.in') }}</span>
</template>
</q-input>
<!-- punch out field -->
<q-input
ref="end_time"
v-model="shift.end_time"
v-bind="timeInputProps"
type="time"
@blur="onTimeFieldBlur(shift.end_time)"
>
<template #label>
<span
class="text-weight-bolder"
:class="shift.is_approved ? ' q-ml-md' : ''"
style="font-size: 0.95em;"
>{{ $t('shared.misc.out') }}</span>
</template>
</q-input>
</div>
<div <div
class="row full-height" class="row full-height"

View File

@ -107,6 +107,7 @@
:is-timesheet-approved="approved" :is-timesheet-approved="approved"
:error-message="shift_error_message" :error-message="shift_error_message"
:dense="dense" :dense="dense"
:current-shifts="day.shifts"
:has-shift-after="shift_index < day.shifts.length - 1" :has-shift-after="shift_index < day.shifts.length - 1"
@request-delete="deleteCurrentShift(shift)" @request-delete="deleteCurrentShift(shift)"
@on-time-field-blur="onTimeFieldBlur()" @on-time-field-blur="onTimeFieldBlur()"
@ -116,6 +117,7 @@
v-else v-else
v-model:shift="day.shifts[shift_index]!" v-model:shift="day.shifts[shift_index]!"
:holiday="holiday" :holiday="holiday"
:current-shifts="day.shifts"
:is-timesheet-approved="approved" :is-timesheet-approved="approved"
:error-message="shift_error_message" :error-message="shift_error_message"
@request-delete="deleteCurrentShift(shift)" @request-delete="deleteCurrentShift(shift)"

View File

@ -2,21 +2,30 @@
setup setup
lang="ts" lang="ts"
> >
import { computed, onMounted } from 'vue';
import { useAuthStore } from 'src/stores/auth-store'; import { useAuthStore } from 'src/stores/auth-store';
import { useTimesheetStore } from 'src/stores/timesheet-store';
import { getHoursMinutesStringFromHoursFloat } from 'src/utils/date-and-time-utils'; import { getHoursMinutesStringFromHoursFloat } from 'src/utils/date-and-time-utils';
const { mode = 'totals', timesheetMode = 'normal', totalHours = 0, vacationHours = 0, sickHours = 0, bankedHours = 0, totalExpenses = 0 } = defineProps<{ const { mode = 'totals', timesheetMode = 'normal', totalHours = 0, totalExpenses = 0 } = defineProps<{
mode: 'total-hours' | 'off-hours'; mode: 'total-hours' | 'off-hours';
timesheetMode: 'approval' | 'normal'; timesheetMode: 'approval' | 'normal';
totalHours?: number; totalHours?: number;
vacationHours?: number;
sickHours?: number;
bankedHours?: number;
totalExpenses?: number; totalExpenses?: number;
}>(); }>();
const auth_store = useAuthStore(); const auth_store = useAuthStore();
const timesheetStore = useTimesheetStore();
const is_management = auth_store.user?.user_module_access.includes('timesheets_approval'); const is_management = auth_store.user?.user_module_access.includes('timesheets_approval');
const vacationHours = computed(() => timesheetStore.paid_time_off_totals.vacation_hours);
const sickHours = computed(() => timesheetStore.paid_time_off_totals.sick_hours);
const bankedHours = computed(() => timesheetStore.paid_time_off_totals.banked_hours);
onMounted(async () => {
if (timesheetMode === 'normal')
await timesheetStore.getPaidTimeOffTotalsWithOptionalEmployeeEmail();
})
</script> </script>
<template> <template>

View File

@ -10,14 +10,17 @@
import TimesheetErrorWidget from 'src/modules/timesheets/components/timesheet-error-widget.vue'; import TimesheetErrorWidget from 'src/modules/timesheets/components/timesheet-error-widget.vue';
import ShiftListWeeklyOverview from 'src/modules/timesheets/components/shift-list-weekly-overview.vue'; import ShiftListWeeklyOverview from 'src/modules/timesheets/components/shift-list-weekly-overview.vue';
import ShiftListWeeklyOverviewMobile from 'src/modules/timesheets/components/mobile/shift-list-weekly-overview-mobile.vue'; import ShiftListWeeklyOverviewMobile from 'src/modules/timesheets/components/mobile/shift-list-weekly-overview-mobile.vue';
import UnsavedChangesDialog from 'src/modules/timesheets/components/unsaved-changes-dialog.vue';
import { date, Notify } from 'quasar'; import { date, Notify } from 'quasar';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { computed, onMounted, provide } from 'vue'; import { computed, onMounted, provide } from 'vue';
import { useShiftApi } from 'src/modules/timesheets/composables/use-shift-api'; import { useShiftApi } from 'src/modules/timesheets/composables/use-shift-api';
import { useTimesheetApi } from 'src/modules/timesheets/composables/use-timesheet-api'; import { useTimesheetApi } from 'src/modules/timesheets/composables/use-timesheet-api';
import { useExpensesStore } from 'src/stores/expense-store'; import { useExpensesStore } from 'src/stores/expense-store';
import { useTimesheetStore } from 'src/stores/timesheet-store'; import { useTimesheetStore } from 'src/stores/timesheet-store';
import { RouteNames } from 'src/router/router-constants';
// ================= state ==================== // ================= state ====================
@ -27,18 +30,25 @@
}>(); }>();
const { t } = useI18n(); const { t } = useI18n();
const expenses_store = useExpensesStore(); const router = useRouter();
const timesheet_store = useTimesheetStore(); const expenseStore = useExpensesStore();
const timesheet_api = useTimesheetApi(); const timesheetStore = useTimesheetStore();
const shift_api = useShiftApi(); const timesheetApi = useTimesheetApi();
const shiftApi = useShiftApi();
// ================== computed ==================== // ================== computed ====================
const has_shift_errors = computed(() => timesheet_store.all_current_shifts.filter(shift => shift.has_error === true).length > 0); const hasShiftErrors = computed(() => timesheetStore.all_current_shifts.filter(shift => shift.has_error === true).length > 0);
const isTimesheetsApproved = computed(() => timesheetStore.timesheets.every(timesheet => timesheet.is_approved));
// const timesheetStore.canSaveTimesheets = computed(() => {
// /* eslint-disable-next-line */
// const currentShifts = timesheetStore.timesheets.flatMap(timesheet => timesheet.days.flatMap(day => day.shifts.map(shift => { const { has_error, ...shft } = shift; return shft; })));
// const initialShifts = timesheetStore.initial_timesheets.flatMap(timesheet => timesheet.days.flatMap(day => day.shifts));
const is_timesheets_approved = computed(() => timesheet_store.timesheets.every(timesheet => timesheet.is_approved)) // return JSON.stringify(currentShifts) !== JSON.stringify(initialShifts);
// });
const total_hours = computed(() => timesheet_store.timesheets.reduce((sum, timesheet) => const totalHours = computed(() => timesheetStore.timesheets.reduce((sum, timesheet) =>
sum += timesheet.weekly_hours.regular sum += timesheet.weekly_hours.regular
+ timesheet.weekly_hours.evening + timesheet.weekly_hours.evening
+ timesheet.weekly_hours.emergency + timesheet.weekly_hours.emergency
@ -46,7 +56,7 @@
0) //initial value 0) //initial value
); );
const total_expenses = computed(() => timesheet_store.timesheets.reduce((sum, timesheet) => const totalExpenses = computed(() => timesheetStore.timesheets.reduce((sum, timesheet) =>
sum + timesheet.weekly_expenses.expenses sum + timesheet.weekly_expenses.expenses
+ timesheet.weekly_expenses.on_call + timesheet.weekly_expenses.on_call
+ timesheet.weekly_expenses.per_diem, + timesheet.weekly_expenses.per_diem,
@ -60,33 +70,55 @@
const onClickSaveTimesheets = async () => { const onClickSaveTimesheets = async () => {
if (mode === 'normal') { if (mode === 'normal') {
await shift_api.saveShiftChanges(); await shiftApi.saveShiftChanges();
Notify.create({ Notify.create({
message: t('timesheet.save_successful'), message: t('timesheet.save_successful'),
color: 'accent', color: 'accent',
}); });
} else { } else {
await shift_api.saveShiftChanges(timesheet_store.current_pay_period_overview?.email); await shiftApi.saveShiftChanges(timesheetStore.current_pay_period_overview?.email);
} }
} }
const onClickLeave = async () => {
timesheetStore.isShowingUnsavedWarning = false;
timesheetStore.timesheets = [];
timesheetStore.initial_timesheets = [];
await router.push({ name: timesheetStore.nextPageNameAfterUnsaveWarning ?? RouteNames.DASHBOARD});
}
const onClickSaveBeforeLeaving = async () => {
if (mode === 'approval') return;
timesheetStore.isShowingUnsavedWarning = false;
await onClickSaveTimesheets();
await onClickLeave();
}
onMounted(async () => { onMounted(async () => {
if (mode === 'normal') if (mode === 'normal')
await timesheet_api.getTimesheetsByDate(date.formatDate(new Date(), 'YYYY-MM-DD')); await timesheetApi.getTimesheetsByDate(date.formatDate(new Date(), 'YYYY-MM-DD'));
}); });
</script> </script>
<template> <template>
<div class="column items-center full-height" :class="mode === 'normal' ? 'relative-position' : ' no-wrap'"> <div
<LoadingOverlay v-model="timesheet_store.is_loading" /> class="column items-center full-height"
:class="mode === 'normal' ? 'relative-position' : ' no-wrap'"
>
<LoadingOverlay v-model="timesheetStore.is_loading" />
<!-- label for approval mode to delimit that this is the timesheet --> <!-- label for approval mode to delimit that this is the timesheet -->
<span <div
v-if="mode === 'approval'" v-if="mode === 'approval'"
class="col-auto text-uppercase text-bold text-h5" class="col-auto row full-width q-px-xl"
> >
{{ $t('timesheet.page_header') }} <span class="col-auto text-uppercase text-bold text-h5">
</span> {{ $t('timesheet.page_header') }}
</span>
<q-space />
</div>
<!-- weekly overview --> <!-- weekly overview -->
@ -99,8 +131,8 @@
<ShiftListWeeklyOverview <ShiftListWeeklyOverview
mode="total-hours" mode="total-hours"
:timesheet-mode="mode" :timesheet-mode="mode"
:total-hours="total_hours" :total-hours="totalHours"
:total-expenses="total_expenses" :total-expenses="totalExpenses"
/> />
</div> </div>
@ -127,9 +159,9 @@
<!-- navigation btn --> <!-- navigation btn -->
<PayPeriodNavigator <PayPeriodNavigator
class="col-auto" class="col-auto"
@date-selected="timesheet_api.getTimesheetsByDate" @date-selected="timesheetApi.getTimesheetsByDate"
@pressed-previous-button="timesheet_api.getTimesheetsByCurrentPayPeriod" @pressed-previous-button="timesheetApi.getTimesheetsByCurrentPayPeriod"
@pressed-next-button="timesheet_api.getTimesheetsByCurrentPayPeriod" @pressed-next-button="timesheetApi.getTimesheetsByCurrentPayPeriod"
/> />
<!-- mobile expenses button --> <!-- mobile expenses button -->
@ -143,7 +175,7 @@
color="accent" color="accent"
icon="receipt_long" icon="receipt_long"
class="full-width" class="full-width"
@click="expenses_store.open" @click="expenseStore.open"
/> />
</div> </div>
@ -157,16 +189,16 @@
color="accent" color="accent"
icon="receipt_long" icon="receipt_long"
:label="$t('timesheet.expense.open_btn')" :label="$t('timesheet.expense.open_btn')"
@click="expenses_store.open" @click="expenseStore.open"
/> />
<!-- desktop save timesheet changes button --> <!-- desktop save timesheet changes button -->
<q-btn <q-btn
v-if="!is_timesheets_approved && $q.screen.width > $q.screen.height" v-if="!isTimesheetsApproved && $q.screen.width > $q.screen.height"
push push
rounded rounded
:disable="timesheet_store.is_loading || has_shift_errors" :disable="timesheetStore.is_loading || hasShiftErrors || !timesheetStore.canSaveTimesheets"
:color="timesheet_store.is_loading || has_shift_errors ? 'grey-5' : 'accent'" :color="timesheetStore.is_loading || hasShiftErrors || !timesheetStore.canSaveTimesheets ? 'grey-5' : 'accent'"
icon="upload" icon="upload"
:label="$t('shared.label.save')" :label="$t('shared.label.save')"
:class="$q.platform.is.mobile && ($q.screen.width < $q.screen.height) ? 'full-width' : 'q-ml-md'" :class="$q.platform.is.mobile && ($q.screen.width < $q.screen.height) ? 'full-width' : 'q-ml-md'"
@ -194,14 +226,14 @@
:class="$q.platform.is.mobile ? 'fit no-wrap' : 'full-width'" :class="$q.platform.is.mobile ? 'fit no-wrap' : 'full-width'"
:style="$q.platform.is.mobile && $q.screen.width < $q.screen.height ? 'margin-bottom: 40px' : ''" :style="$q.platform.is.mobile && $q.screen.width < $q.screen.height ? 'margin-bottom: 40px' : ''"
> >
<!-- Show if no timesheets found (further than one month from present) --> <!-- If no timesheets found -->
<div <div
v-if="timesheet_store.timesheets.length < 1 && !timesheet_store.is_loading" v-if="timesheetStore.timesheets.length < 1 && !timesheetStore.is_loading"
class="col-auto column flex-center fit q-py-lg" class="col-auto column flex-center fit q-py-lg"
style="min-height: 20vh;" style="min-height: 20vh;"
> >
<span class="text-uppercase text-weight-bolder text-center">{{ $t('shared.error.no_data_found') <span class="text-uppercase text-weight-bolder text-center">{{ $t('shared.error.no_data_found')
}}</span> }}</span>
<q-icon <q-icon
name="las la-calendar" name="las la-calendar"
color="accent" color="accent"
@ -211,7 +243,7 @@
/> />
</div> </div>
<!-- Else show timesheets if found --> <!-- Else show timesheets -->
<ShiftList <ShiftList
v-else v-else
class="col-auto" class="col-auto"
@ -221,7 +253,7 @@
<q-btn <q-btn
v-if="$q.platform.is.mobile && $q.screen.width < $q.screen.height" v-if="$q.platform.is.mobile && $q.screen.width < $q.screen.height"
square square
:disable="timesheet_store.is_loading" :disable="timesheetStore.is_loading || hasShiftErrors || !timesheetStore.canSaveTimesheets"
size="lg" size="lg"
color="accent" color="accent"
icon="upload" icon="upload"
@ -232,8 +264,13 @@
/> />
<ExpenseDialog <ExpenseDialog
:is-approved="is_timesheets_approved" :is-approved="isTimesheetsApproved"
class="z-top" class="z-top"
/> />
<UnsavedChangesDialog
@click-save-no="onClickLeave"
@click-save-yes="onClickSaveBeforeLeaving"
/>
</div> </div>
</template> </template>

View File

@ -0,0 +1,74 @@
<script
setup
lang="ts"
>
import { useTimesheetStore } from 'src/stores/timesheet-store';
// ========== state ========================================
const emit = defineEmits<{
clickSaveNo: [];
clickSaveYes: [];
}>();
const timesheetStore = useTimesheetStore();
// ========== methods ========================================
const onClickSaveOptionButton = (option: 'cancel' | 'no' | 'yes') => {
switch(option) {
case 'cancel':
timesheetStore.isShowingUnsavedWarning = false;
break;
case 'no':
emit('clickSaveNo');
break;
case 'yes':
emit('clickSaveYes');
break;
}
}
</script>
<template>
<q-dialog
v-model="timesheetStore.isShowingUnsavedWarning"
backdrop-filter="blur(4px)"
>
<q-card
class="bg-dark shadow-12 flex-center"
style="border: 2px solid var(--q-accent);"
>
<div class="column flex-center q-py-sm q-px-md text-center">
<span class="col-auto text-bold text-uppercase" style="font-size: 1.2em;">
{{ $t('timesheet.unsaved_changes_title') }}
</span>
<span class="">{{ $t('timesheet.unsaved_changes_caption') }}</span>
</div>
<div class="col-auto row full-width">
<q-btn
flat
:label="$t('shared.label.cancel')"
class="col"
@click="onClickSaveOptionButton('cancel')"
/>
<q-btn
flat
color="negative"
:label="$t('shared.misc.no')"
class="col"
@click="onClickSaveOptionButton('no')"
/>
<q-btn
flat
color="accent"
:label="$t('shared.misc.yes')"
class="col"
@click="onClickSaveOptionButton('yes')"
/>
</div>
</q-card>
</q-dialog>
</template>

View File

@ -8,10 +8,18 @@ export const useExpensesApi = () => {
const expenses_store = useExpensesStore(); const expenses_store = useExpensesStore();
const timesheet_store = useTimesheetStore(); const timesheet_store = useTimesheetStore();
const upsertExpense = async (expense: Expense, file: File, employee_email: string): Promise<string> => { const upsertExpense = async (expense: Expense, employee_email: string, file?: File): Promise<string> => {
const presignedURL = expenses_store.uploadAttachment(file); if (file) {
if (!presignedURL) return 'PRESIGN_FAILED'; const attachmentKey = await expenses_store.uploadAttachment(file);
if (!attachmentKey)
console.error('failed to upload attachment');
else {
expense.attachment_key = attachmentKey;
expense.attachment_name = file.name;
}
}
console.log('employee email provided for expense: ', employee_email)
const success = await expenses_store.upsertExpense(expense, employee_email); const success = await expenses_store.upsertExpense(expense, employee_email);
if (success) { if (success) {

View File

@ -2,31 +2,35 @@ import { useShiftStore } from "src/stores/shift-store";
import { useTimesheetStore } from "src/stores/timesheet-store"; import { useTimesheetStore } from "src/stores/timesheet-store";
export const useShiftApi = () => { export const useShiftApi = () => {
const timesheet_store = useTimesheetStore(); const timesheetStore = useTimesheetStore();
const shift_store = useShiftStore(); const shift_store = useShiftStore();
const deleteShiftById = async (shift_id: number, employee_email?: string) => { const deleteShiftById = async (shift_id: number, employee_email?: string) => {
timesheet_store.is_loading = true; timesheetStore.is_loading = true;
const success = await shift_store.deleteShiftById(shift_id, employee_email); const success = await shift_store.deleteShiftById(shift_id, employee_email);
if (success) { if (success) {
await timesheet_store.getTimesheetsByOptionalEmployeeEmail(employee_email); timesheetStore.timesheets = [];
await timesheetStore.getTimesheetsByOptionalEmployeeEmail(employee_email);
await timesheetStore.getPaidTimeOffTotalsWithOptionalEmployeeEmail(employee_email);
} }
timesheet_store.is_loading = false; timesheetStore.is_loading = false;
}; };
const saveShiftChanges = async (employee_email?: string) => { const saveShiftChanges = async (employee_email?: string) => {
timesheet_store.is_loading = true; timesheetStore.is_loading = true;
const update_success = await shift_store.updateShifts(employee_email); const update_success = await shift_store.updateShifts(employee_email);
const create_success = await shift_store.createNewShifts(employee_email); const create_success = await shift_store.createNewShifts(employee_email);
if (create_success || update_success){ if (create_success || update_success){
await timesheet_store.getTimesheetsByOptionalEmployeeEmail(employee_email); timesheetStore.timesheets = [];
await timesheetStore.getTimesheetsByOptionalEmployeeEmail(employee_email);
await timesheetStore.getPaidTimeOffTotalsWithOptionalEmployeeEmail(employee_email);
} }
timesheet_store.is_loading = false; timesheetStore.is_loading = false;
} }
return { return {

View File

@ -1,65 +1,71 @@
import { useShiftStore } from "src/stores/shift-store";
import { useTimesheetStore } from "src/stores/timesheet-store"; import { useTimesheetStore } from "src/stores/timesheet-store";
import { timesheetService } from "src/modules/timesheets/services/timesheet-service";
export const useTimesheetApi = () => { export const useTimesheetApi = () => {
const timesheet_store = useTimesheetStore(); const timesheetStore = useTimesheetStore();
const shiftStore = useShiftStore();
const getTimesheetsByDate = async (date_string: string, employee_email?: string) => { const getTimesheetsByDate = async (date_string: string, employeeEmail?: string) => {
timesheet_store.timesheets = []; timesheetStore.timesheets = [];
timesheet_store.is_loading = true; timesheetStore.is_loading = true;
const success = await timesheet_store.getPayPeriodByDateOrYearAndNumber(date_string); const success = await timesheetStore.getPayPeriodByDateOrYearAndNumber(date_string);
if (success) { if (success) {
await timesheet_store.getTimesheetsByOptionalEmployeeEmail(employee_email); await timesheetStore.getTimesheetsByOptionalEmployeeEmail(employeeEmail);
timesheet_store.is_loading = false; await timesheetStore.getPaidTimeOffTotalsWithOptionalEmployeeEmail(employeeEmail);
timesheetStore.is_loading = false;
} }
timesheet_store.is_loading = false; timesheetStore.is_loading = false;
} }
const getTimesheetsByCurrentPayPeriod = async (employee_email?: string) => { const getTimesheetsByCurrentPayPeriod = async (employeeEmail?: string) => {
if (timesheet_store.pay_period === undefined) return false; if (timesheetStore.pay_period === undefined) return false;
timesheet_store.is_loading = true; timesheetStore.is_loading = true;
const success = await timesheet_store.getPayPeriodByDateOrYearAndNumber(); const success = await timesheetStore.getPayPeriodByDateOrYearAndNumber();
if (success) { if (success) {
await timesheet_store.getTimesheetsByOptionalEmployeeEmail(employee_email); await timesheetStore.getTimesheetsByOptionalEmployeeEmail(employeeEmail);
timesheet_store.is_loading = false; await timesheetStore.getPaidTimeOffTotalsWithOptionalEmployeeEmail(employeeEmail);
timesheetStore.is_loading = false;
} }
timesheet_store.is_loading = false; timesheetStore.is_loading = false;
}; };
const applyPreset = async (timesheet_id: number, week_day_index?: number, date?: string, employeeEmail?: string) => { const applyPreset = async (timesheet_id: number, week_day_index?: number, date?: string, employeeEmail?: string) => {
if (timesheet_store.timesheets.map(timesheet => timesheet.timesheet_id).includes(timesheet_id)) { timesheetStore.is_loading = true;
timesheet_store.is_loading = true;
try {
let response;
if (week_day_index && date) const success = await timesheetStore.applyPreset(timesheet_id, week_day_index, date, employeeEmail);
response = await timesheetService.applyPresetToDay(timesheet_id, week_day_index, date, employeeEmail);
else
response = await timesheetService.applyPresetToWeek(timesheet_id, employeeEmail);
if (response.success) if (!success) {
await timesheet_store.getTimesheetsByOptionalEmployeeEmail(employeeEmail); timesheetStore.is_loading = false;
} catch (error) { return;
console.error('Error applying weekly timesheet: ', error);
}
timesheet_store.is_loading = false;
} }
const timesheets = JSON.stringify(timesheetStore.timesheets);
const initialTimesheets = JSON.stringify(timesheetStore.initial_timesheets);
if (timesheets !== initialTimesheets) {
await shiftStore.updateShifts(employeeEmail);
await shiftStore.createNewShifts(employeeEmail);
}
await timesheetStore.getTimesheetsByOptionalEmployeeEmail(employeeEmail);
await timesheetStore.getPaidTimeOffTotalsWithOptionalEmployeeEmail(employeeEmail);
timesheetStore.is_loading = false;
} }
const getTimesheetsBySwiping = async( direction: number ) => { const getTimesheetsBySwiping = async (direction: number) => {
timesheet_store.is_loading = true; timesheetStore.is_loading = true;
timesheet_store.getNextOrPreviousPayPeriod(direction); timesheetStore.getNextOrPreviousPayPeriod(direction);
await timesheet_store.getPayPeriodByDateOrYearAndNumber(); await timesheetStore.getPayPeriodByDateOrYearAndNumber();
await timesheet_store.getTimesheetsByOptionalEmployeeEmail(); await timesheetStore.getTimesheetsByOptionalEmployeeEmail();
timesheet_store.is_loading = false; timesheetStore.is_loading = false;
} }
return { return {

View File

@ -32,4 +32,9 @@ export interface ExpenseOption {
label: string; label: string;
value: ExpenseType; value: ExpenseType;
icon: string; icon: string;
}
export interface AttachmentPresignedURLResponse {
url: string;
key: string;
} }

View File

@ -1,3 +1,5 @@
import type { QSelectOption } from "quasar";
export const SHIFT_TYPES: ShiftType[] = [ export const SHIFT_TYPES: ShiftType[] = [
'REGULAR', 'REGULAR',
'EVENING', 'EVENING',
@ -39,9 +41,10 @@ export class Shift {
} }
} }
export interface ShiftOption { export interface ShiftOption extends QSelectOption {
label: string; label: string;
value: ShiftType; value: ShiftType;
icon: string; icon: string;
icon_color: string; icon_color: string;
disable?: boolean;
} }

View File

@ -7,6 +7,7 @@ export const DATE_FORMAT_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
export interface TimesheetResponse { export interface TimesheetResponse {
has_preset_schedule: boolean; has_preset_schedule: boolean;
employee_fullname: string; employee_fullname: string;
daily_expected_hours: number;
timesheets: Timesheet[]; timesheets: Timesheet[];
} }

View File

@ -1,10 +1,10 @@
import { api } from "src/boot/axios"; import { api } from "src/boot/axios";
import type { BackendResponse } from "src/modules/shared/models/backend-response.models"; import type { BackendResponse } from "src/modules/shared/models/backend-response.models";
import type { Expense } from "src/modules/timesheets/models/expense.models"; import type { AttachmentPresignedURLResponse, Expense } from "src/modules/timesheets/models/expense.models";
export const ExpenseService = { export const ExpenseService = {
createExpense: async (expense: Expense): Promise<{ success: boolean, data: Expense, error?: unknown }> => { createExpense: async (expense: Expense, email?: string): Promise<{ success: boolean, data: Expense, error?: unknown }> => {
const response = await api.post('expense/create', expense); const response = await api.post(`expense/create${email ? '?employee_email=' + email : ''}`, expense);
return response.data; return response.data;
}, },
@ -18,14 +18,19 @@ export const ExpenseService = {
return response.data; return response.data;
}, },
getPresignedUploadURL: async (file: File, checksum_crc32: string): Promise<BackendResponse<string>> => { getPresignedUploadURL: async (file: File, checksum_crc32: string): Promise<BackendResponse<AttachmentPresignedURLResponse>> => {
const [file_name, file_type] = file.name.split('.'); const [file_name, file_type] = file.name.split('.');
const response = await api.post(`attachments/s3/upload?file-name=${file_name}&file-type=${file_type}&checksumCRC32=${checksum_crc32}`); const response = await api.post(`attachments/s3/upload?file-name=${file_name}&file-type=${file_type}&checksumCRC32=${checksum_crc32}`);
return response.data; return response.data;
}, },
uploadAttachmentWithPresignedUrl: async (file: File, url: string) => { uploadAttachmentWithPresignedUrl: async (file: File, url: string): Promise<number> => {
const response = await api.put(url, file, { headers: { 'Content-Type': file.type, }, withCredentials: false }); const response = await api.put(url, file, { headers: { 'Content-Type': `image/${file.type}`, }, withCredentials: false });
console.log('response to upload: ', response); return response.status;
},
getPresignedDownloadURL: async (key: string): Promise<BackendResponse<string>> => {
const response = await api.get<BackendResponse<string>>(`attachments/s3/download?attachmentKey=${key}`);
return response.data;
} }
}; };

View File

@ -4,6 +4,7 @@ import type { TimesheetResponse } from "src/modules/timesheets/models/timesheet.
import type { TimesheetApprovalOverview } from "src/modules/timesheet-approval/models/timesheet-overview.models"; import type { TimesheetApprovalOverview } from "src/modules/timesheet-approval/models/timesheet-overview.models";
import type { BackendResponse } from "src/modules/shared/models/backend-response.models"; import type { BackendResponse } from "src/modules/shared/models/backend-response.models";
import type { FederalHoliday } from "src/modules/timesheets/models/federal-holidays.models"; import type { FederalHoliday } from "src/modules/timesheets/models/federal-holidays.models";
import type { PaidTimeOff } from "src/modules/employee-list/models/employee-profile.models";
export const timesheetService = { export const timesheetService = {
getAllFederalHolidays: async (year?: number): Promise<FederalHoliday[]> => { getAllFederalHolidays: async (year?: number): Promise<FederalHoliday[]> => {
@ -36,6 +37,16 @@ export const timesheetService = {
} }
}, },
getPaidTimeOffTotalsWithOptionalEmployeeEmail: async (email?: string): Promise<BackendResponse<PaidTimeOff>> => {
if (email) {
const response = await api.get<BackendResponse<PaidTimeOff>>(`paid-time-off/totals?email=${email}`);
return response.data;
}
const response = await api.get<BackendResponse<PaidTimeOff>>('paid-time-off/totals');
return response.data;
},
applyPresetToWeek: async (timesheet_id: number, employeeEmail?: string): Promise<BackendResponse<boolean>> => { applyPresetToWeek: async (timesheet_id: number, employeeEmail?: string): Promise<BackendResponse<boolean>> => {
if (employeeEmail) { if (employeeEmail) {
const response = await api.post<BackendResponse<boolean>>(`schedule-presets/apply-preset/${employeeEmail}`, { timesheet_id }); const response = await api.post<BackendResponse<boolean>>(`schedule-presets/apply-preset/${employeeEmail}`, { timesheet_id });

View File

@ -1,38 +1,82 @@
import { date } from "quasar"; import { date } from "quasar";
import type { SchedulePresetShift } from "src/modules/employee-list/models/schedule-presets.models"; import type { SchedulePresetShift } from "src/modules/employee-list/models/schedule-presets.models";
import type { Shift, ShiftOption } from "src/modules/timesheets/models/shift.models"; import type { Shift, ShiftOption, ShiftType } from "src/modules/timesheets/models/shift.models";
export const isShiftOverlap = (shifts: Shift[] | SchedulePresetShift[]): boolean => { export const isShiftOverlap = (shifts: Shift[] | SchedulePresetShift[]): boolean => {
if (shifts.length < 2) return false; if (shifts.length < 2) return false;
const parsed_shifts = shifts.map(shift => ({ const parsed_shifts = shifts.map(shift => ({
start: date.extractDate(`2000-01-01 ${shift.start_time}`, 'YYYY-MM-DD HH:mm').getTime(), start: date.extractDate(`2000-01-01 ${shift.start_time}`, 'YYYY-MM-DD HH:mm').getTime(),
end: date.extractDate(`2000-01-01 ${shift.end_time}`, 'YYYY-MM-DD HH:mm').getTime(), end: date.extractDate(`2000-01-01 ${shift.end_time}`, 'YYYY-MM-DD HH:mm').getTime(),
})); }));
for (let i = 0; i < parsed_shifts.length; i++) { for (let i = 0; i < parsed_shifts.length; i++) {
for (let j = i + 1; j < parsed_shifts.length; j++) { for (let j = i + 1; j < parsed_shifts.length; j++) {
const parsed_shift_a = parsed_shifts[i]; const parsed_shift_a = parsed_shifts[i];
const parsed_shift_b = parsed_shifts[j]; const parsed_shift_b = parsed_shifts[j];
if (parsed_shift_a === undefined || parsed_shift_b === undefined) continue; if (parsed_shift_a === undefined || parsed_shift_b === undefined) continue;
if (Math.max(parsed_shift_a.start, parsed_shift_b.start) < Math.min(parsed_shift_a.end, parsed_shift_b.end)) { if (Math.max(parsed_shift_a.start, parsed_shift_b.start) < Math.min(parsed_shift_a.end, parsed_shift_b.end)) {
return true; // overlap found return true; // overlap found
} }
} }
} }
return false; return false;
}; };
export const getCurrentDailyMinutesWorked = (shifts: Shift[]): number => {
const shiftTypesToIgnore: ShiftType[] = ['HOLIDAY', 'SICK', 'VACATION'];
let minutes = 0;
shifts.forEach(shift => {
if (shiftTypesToIgnore.includes(shift.type)) return;
const startTime = new Date(`1970-01-01T${shift.start_time}:00`);
const endTime = new Date(`1970-01-01T${shift.end_time}:00`);
const diff = date.getDateDiff(endTime, startTime, 'minutes');
minutes += diff;
});
return minutes;
}
export const getTimeStringFromMinutes = (minutes: number): string => {
const h = Math.floor(minutes / 60);
const m = minutes % 60;
if (h < 10 ) {
if (m < 10)
return `0${h}:0${m}`;
return `0${h}:${m}`;
}
return `${h}:${m}`;
}
export const SHIFT_OPTIONS: ShiftOption[] = [ export const SHIFT_OPTIONS: ShiftOption[] = [
{ label: 'timesheet.shift.types.REGULAR', value: 'REGULAR', icon: 'wb_sunny', icon_color: 'accent' }, { label: 'timesheet.shift.types.REGULAR', value: 'REGULAR', icon: 'wb_sunny', icon_color: 'accent' },
{ label: 'timesheet.shift.types.EVENING', value: 'EVENING', icon: 'bedtime', icon_color: 'orange-5' }, { label: 'timesheet.shift.types.EVENING', value: 'EVENING', icon: 'bedtime', icon_color: 'orange-5' },
{ label: 'timesheet.shift.types.EMERGENCY', value: 'EMERGENCY', icon: 'ring_volume', icon_color: 'red-5' }, { label: 'timesheet.shift.types.EMERGENCY', value: 'EMERGENCY', icon: 'ring_volume', icon_color: 'red-5' },
{ label: 'timesheet.shift.types.VACATION', value: 'VACATION', icon: 'beach_access', icon_color: 'deep-orange-5' }, { label: 'timesheet.shift.types.VACATION', value: 'VACATION', icon: 'beach_access', icon_color: 'deep-orange-5' },
{ label: 'timesheet.shift.types.HOLIDAY', value: 'HOLIDAY', icon: 'event', icon_color: 'purple-5' }, { label: 'timesheet.shift.types.HOLIDAY', value: 'HOLIDAY', icon: 'event', icon_color: 'purple-5' },
{ label: 'timesheet.shift.types.SICK', value: 'SICK', icon: 'medication_liquid', icon_color: 'light-blue-6' }, { label: 'timesheet.shift.types.SICK', value: 'SICK', icon: 'medication_liquid', icon_color: 'light-blue-6' },
// { label: 'timesheet.shift.types.BANKING', value: 'BANKING', icon: 'savings', icon_color: 'pink-3' }, // { label: 'timesheet.shift.types.BANKING', value: 'BANKING', icon: 'savings', icon_color: 'pink-3' },
{ label: 'timesheet.shift.types.WITHDRAW_BANKED', value: 'WITHDRAW_BANKED', icon: 'attach_money', icon_color: 'yellow-4' }, { label: 'timesheet.shift.types.WITHDRAW_BANKED', value: 'WITHDRAW_BANKED', icon: 'attach_money', icon_color: 'yellow-4' },
]; ];
export const getShiftOptions = (disablePTO: boolean, isNotUnique: boolean): ShiftOption[] => {
return [
{ label: 'timesheet.shift.types.REGULAR', value: 'REGULAR', icon: 'wb_sunny', icon_color: 'accent' },
{ label: 'timesheet.shift.types.EVENING', value: 'EVENING', icon: 'bedtime', icon_color: 'orange-5' },
{ label: 'timesheet.shift.types.EMERGENCY', value: 'EMERGENCY', icon: 'ring_volume', icon_color: 'red-5' },
{ label: 'timesheet.shift.types.VACATION', value: 'VACATION', icon: 'beach_access', icon_color: 'deep-orange-5', disable: disablePTO },
{ label: 'timesheet.shift.types.HOLIDAY', value: 'HOLIDAY', icon: 'event', icon_color: 'purple-5', disable: isNotUnique || disablePTO },
{ label: 'timesheet.shift.types.SICK', value: 'SICK', icon: 'medication_liquid', icon_color: 'light-blue-6', disable: disablePTO },
// { label: 'timesheet.shift.types.BANKING', value: 'BANKING', icon: 'savings', icon_color: 'pink-3' },
// { label: 'timesheet.shift.types.WITHDRAW_BANKED', value: 'WITHDRAW_BANKED', icon: 'attach_money', icon_color: 'yellow-4' },
];
}

View File

@ -5,6 +5,7 @@ import { useAuthStore } from 'src/stores/auth-store';
import { RouteNames } from 'src/router/router-constants'; import { RouteNames } from 'src/router/router-constants';
import { useChatbotStore } from 'src/stores/chatbot-store'; import { useChatbotStore } from 'src/stores/chatbot-store';
import type { UserModuleAccess } from 'src/modules/shared/models/user.models'; import type { UserModuleAccess } from 'src/modules/shared/models/user.models';
import { useTimesheetStore } from 'src/stores/timesheet-store';
/* /*
* If not building with SSR mode, you can * If not building with SSR mode, you can
@ -30,19 +31,29 @@ export default defineRouter(function (/* { store, ssrContext } */) {
history: createHistory(process.env.VUE_ROUTER_BASE), history: createHistory(process.env.VUE_ROUTER_BASE),
}); });
Router.beforeEach(async (destination_page) => { Router.beforeEach(async (to, from) => {
const auth_store = useAuthStore(); const auth_store = useAuthStore();
const result = await auth_store.getProfile() ?? { status: 400, message: 'unknown error occured' }; const result = await auth_store.getProfile() ?? { status: 400, message: 'unknown error occured' };
if (destination_page.meta.requires_auth && !auth_store.user || (result.status >= 400 && destination_page.name !== RouteNames.LOGIN)) { if (to.meta.requires_auth && !auth_store.user || (result.status >= 400 && to.name !== RouteNames.LOGIN)) {
console.error('no user account found'); console.error('no user account found');
return { name: 'login' }; return { name: 'login' };
} }
if (destination_page.meta.required_module && auth_store.user) { if (to.meta.required_module && auth_store.user) {
if (!auth_store.user.user_module_access.includes(destination_page.meta.required_module as UserModuleAccess)) if (!auth_store.user.user_module_access.includes(to.meta.required_module as UserModuleAccess))
return {name: 'error'}; return {name: 'error'};
} }
if (from.name === RouteNames.TIMESHEET) {
const timesheetStore = useTimesheetStore();
if(timesheetStore.canSaveTimesheets) {
timesheetStore.nextPageNameAfterUnsaveWarning = to.name;
timesheetStore.isShowingUnsavedWarning = true;
return false;
}
}
}) })
Router.afterEach( (destination_page) => { Router.afterEach( (destination_page) => {

View File

@ -29,12 +29,8 @@ export const useAuthStore = defineStore('auth', () => {
}); });
}; };
const logout = () => { const logout = async () => {
user.value = undefined; await AuthService.logout();
AuthService.logout();
const logout_popup = window.open('https://auth.targo.ca/application/o/montargo/end-session/', 'logoutPopup', 'width=200,height=200');
setInterval(() => logout_popup?.close(), 2000);
}; };
const handleAuthMessage = async (event: MessageEvent) => { const handleAuthMessage = async (event: MessageEvent) => {

View File

@ -16,17 +16,18 @@ export const useChatbotStore = defineStore("chatbot", () => {
try { try {
const chatbot_response = await chatbotService.sendChatMessage(user_message, current_page_context.value); const chatbot_response = await chatbotService.sendChatMessage(user_message, current_page_context.value);
if (chatbot_response) {
if (chatbot_response.text) {
last_chatbot_message.text = chatbot_response.text; last_chatbot_message.text = chatbot_response.text;
last_chatbot_message.isThinking = false; last_chatbot_message.isThinking = false;
} else { } else {
last_chatbot_message.text = 'chatbot.error.NO_REPLY_RECEIVED'; last_chatbot_message.text = 'chatbot.error.NO_REPLY_RECEIVED';
last_chatbot_message.isThinking = false; last_chatbot_message.isThinking = false;
} }
} } catch (error) {
catch (error) {
last_chatbot_message.text = 'chatbot.error.SEND_MESSAGE_FAILED'; last_chatbot_message.text = 'chatbot.error.SEND_MESSAGE_FAILED';
last_chatbot_message.isThinking = false; last_chatbot_message.isThinking = false;
console.error('error sending message: ', error); console.error('error sending message: ', error);
} }
}; };

View File

@ -11,9 +11,11 @@ export const useExpensesStore = defineStore('expenses', () => {
const is_open = ref(false); const is_open = ref(false);
const is_loading = ref(false); const is_loading = ref(false);
const is_showing_create_form = ref(false); const is_showing_create_form = ref(false);
const attachmentURL = ref<string>('');
const mode = ref<'create' | 'update' | 'delete'>('create'); const mode = ref<'create' | 'update' | 'delete'>('create');
const current_expense = ref<Expense>(new Expense(date.formatDate(new Date(), 'YYYY-MM-DD'))); const current_expense = ref<Expense>(new Expense(date.formatDate(new Date(), 'YYYY-MM-DD')));
const initial_expense = ref<Expense>(new Expense(date.formatDate(new Date(), 'YYYY-MM-DD'))); const initial_expense = ref<Expense>(new Expense(date.formatDate(new Date(), 'YYYY-MM-DD')));
const isShowingAttachmentDialog = ref(false);
const is_save_disabled = computed(() => JSON.stringify(current_expense.value) === JSON.stringify(initial_expense.value)) const is_save_disabled = computed(() => JSON.stringify(current_expense.value) === JSON.stringify(initial_expense.value))
const open = (): void => { const open = (): void => {
@ -34,7 +36,7 @@ export const useExpensesStore = defineStore('expenses', () => {
const upsertExpense = async (expense: Expense, email?: string): Promise<boolean> => { const upsertExpense = async (expense: Expense, email?: string): Promise<boolean> => {
try { try {
if (expense.id < 0) { if (expense.id < 0) {
const data = await ExpenseService.createExpense(expense); const data = await ExpenseService.createExpense(expense, email);
return data.success; return data.success;
} }
const data = await ExpenseService.updateExpense(expense, email); const data = await ExpenseService.updateExpense(expense, email);
@ -51,22 +53,48 @@ export const useExpensesStore = defineStore('expenses', () => {
return data.success; return data.success;
} }
const uploadAttachment = async (file: File) => { /**
* Attemps to upload the provided image file to the S3 storage bucket for attachments.
*
* @param file image file to be uploaded to the S3 storage
* @returns Key `string` associated with the uploaded image file if successful,
* `undefined` if it fails.
*/
const uploadAttachment = async (file: File): Promise<string | undefined> => {
try { try {
const checksum = await computeCRC32Base64(file); const checksum = await computeCRC32Base64(file);
const presignedUrlResponse = await ExpenseService.getPresignedUploadURL(file, checksum); const presignedUrlResponse = await ExpenseService.getPresignedUploadURL(file, checksum);
if (presignedUrlResponse.success && presignedUrlResponse.data) {
const { url, key } = JSON.parse(presignedUrlResponse.data);
console.log('key: ', key);
await ExpenseService.uploadAttachmentWithPresignedUrl(file, url); if (!presignedUrlResponse.success || !presignedUrlResponse.data) {
console.error('failed to get presigned URL from server');
return;
} }
const { url, key } = presignedUrlResponse.data;
const responseStatus = await ExpenseService.uploadAttachmentWithPresignedUrl(file, url);
if (responseStatus >= 400) {
console.error('an error occured during upload: error ', responseStatus);
return;
}
return key;
} catch (error) { } catch (error) {
console.error(error); console.error(error);
} }
} }
const getAttachmentURL = async (key?: string) => {
if (!key)
return;
const presignedAttachmentURL = await ExpenseService.getPresignedDownloadURL(key);
if (presignedAttachmentURL.success && presignedAttachmentURL.data)
attachmentURL.value = presignedAttachmentURL.data;
}
return { return {
is_open, is_open,
is_loading, is_loading,
@ -74,11 +102,14 @@ export const useExpensesStore = defineStore('expenses', () => {
mode, mode,
current_expense, current_expense,
initial_expense, initial_expense,
isShowingAttachmentDialog,
is_save_disabled, is_save_disabled,
attachmentURL,
open, open,
upsertExpense, upsertExpense,
deleteExpenseById, deleteExpenseById,
close, close,
uploadAttachment, uploadAttachment,
getAttachmentURL,
}; };
}); });

View File

@ -8,10 +8,12 @@ import { timesheetService } from 'src/modules/timesheets/services/timesheet-serv
import type { PayPeriodOverviewResponse, TimesheetApprovalOverview } from "src/modules/timesheet-approval/models/timesheet-overview.models"; import type { PayPeriodOverviewResponse, TimesheetApprovalOverview } from "src/modules/timesheet-approval/models/timesheet-overview.models";
import type { PayPeriod } from 'src/modules/shared/models/pay-period.models'; import type { PayPeriod } from 'src/modules/shared/models/pay-period.models';
import type { Timesheet } from 'src/modules/timesheets/models/timesheet.models'; import type { Timesheet } from 'src/modules/timesheets/models/timesheet.models';
import type { PaidTimeOff } from 'src/modules/employee-list/models/employee-profile.models';
import type { PayPeriodEvent } from 'src/modules/timesheet-approval/models/pay-period-event.models'; import type { PayPeriodEvent } from 'src/modules/timesheet-approval/models/pay-period-event.models';
import type { TimesheetApprovalCSVReportFilters } from 'src/modules/timesheet-approval/models/timesheet-approval-csv-report.models'; import type { TimesheetApprovalCSVReportFilters } from 'src/modules/timesheet-approval/models/timesheet-approval-csv-report.models';
import { type FederalHoliday, TARGO_HOLIDAY_NAMES_FR } from 'src/modules/timesheets/models/federal-holidays.models'; import { type FederalHoliday, TARGO_HOLIDAY_NAMES_FR } from 'src/modules/timesheets/models/federal-holidays.models';
import type { RouteNames } from 'src/router/router-constants';
import type { RouteRecordNameGeneric } from 'vue-router';
export const useTimesheetStore = defineStore('timesheet', () => { export const useTimesheetStore = defineStore('timesheet', () => {
const { t } = useI18n(); const { t } = useI18n();
@ -20,6 +22,16 @@ export const useTimesheetStore = defineStore('timesheet', () => {
const timesheets = ref<Timesheet[]>([]); const timesheets = ref<Timesheet[]>([]);
const all_current_shifts = computed(() => timesheets.value.flatMap(week => week.days.flatMap(day => day.shifts)) ?? []); const all_current_shifts = computed(() => timesheets.value.flatMap(week => week.days.flatMap(day => day.shifts)) ?? []);
const initial_timesheets = ref<Timesheet[]>([]); const initial_timesheets = ref<Timesheet[]>([]);
const canSaveTimesheets = computed(() => {
/* eslint-disable-next-line */
const currentShifts = timesheets.value.flatMap(timesheet => timesheet.days.flatMap(day => day.shifts.map(shift => { const { has_error, ...shft } = shift; return shft; })));
const initialShifts = initial_timesheets.value.flatMap(timesheet => timesheet.days.flatMap(day => day.shifts));
return JSON.stringify(currentShifts) !== JSON.stringify(initialShifts);
});
const paid_time_off_totals = ref<PaidTimeOff>({ sick_hours: 0, vacation_hours: 0, banked_hours: 0 });
const isShowingUnsavedWarning = ref(false);
const nextPageNameAfterUnsaveWarning = ref<RouteNames | RouteRecordNameGeneric>();
const pay_period_overviews = ref<TimesheetApprovalOverview[]>([]); const pay_period_overviews = ref<TimesheetApprovalOverview[]>([]);
const pay_period_infos = ref<PayPeriodOverviewResponse>(); const pay_period_infos = ref<PayPeriodOverviewResponse>();
@ -119,7 +131,7 @@ export const useTimesheetStore = defineStore('timesheet', () => {
has_timesheet_preset.value = response.data.has_preset_schedule; has_timesheet_preset.value = response.data.has_preset_schedule;
selected_employee_name.value = response.data.employee_fullname; selected_employee_name.value = response.data.employee_fullname;
timesheets.value = response.data.timesheets; timesheets.value = response.data.timesheets;
initial_timesheets.value = unwrapAndClone(timesheets.value); initial_timesheets.value = unwrapAndClone(response.data.timesheets);
} else { } else {
selected_employee_name.value = ''; selected_employee_name.value = '';
timesheets.value = []; timesheets.value = [];
@ -135,6 +147,18 @@ export const useTimesheetStore = defineStore('timesheet', () => {
} }
}; };
const getPaidTimeOffTotalsWithOptionalEmployeeEmail = async (email?: string) => {
try {
const response = await timesheetService.getPaidTimeOffTotalsWithOptionalEmployeeEmail(email);
if (response.success && response.data)
paid_time_off_totals.value = response.data;
} catch (error) {
console.error('error getting paid time off totals: ', error);
}
};
const toggleTimesheetsApprovalByEmployeeEmail = async (email: string, approval_status: boolean): Promise<boolean> => { const toggleTimesheetsApprovalByEmployeeEmail = async (email: string, approval_status: boolean): Promise<boolean> => {
try { try {
const timesheet_ids = timesheets.value.map(timesheet => timesheet.timesheet_id); const timesheet_ids = timesheets.value.map(timesheet => timesheet.timesheet_id);
@ -148,6 +172,24 @@ export const useTimesheetStore = defineStore('timesheet', () => {
return false; return false;
}; };
const applyPreset = async (timesheet_id: number, week_day_index?: number, date?: string, employeeEmail?: string): Promise<boolean> => {
if (timesheets.value.map(timesheet => timesheet.timesheet_id).includes(timesheet_id)) {
try {
let response;
if (week_day_index && date)
response = await timesheetService.applyPresetToDay(timesheet_id, week_day_index, date, employeeEmail);
else
response = await timesheetService.applyPresetToWeek(timesheet_id, employeeEmail);
return response.success ?? false;
} catch (error) {
console.error('Error applying weekly timesheet: ', error);
}
}
return false;
}
const getPayPeriodReport = async (report_filters: TimesheetApprovalCSVReportFilters) => { const getPayPeriodReport = async (report_filters: TimesheetApprovalCSVReportFilters) => {
try { try {
if (!pay_period.value) return false; if (!pay_period.value) return false;
@ -223,21 +265,27 @@ export const useTimesheetStore = defineStore('timesheet', () => {
current_pay_period_overview, current_pay_period_overview,
pay_period_infos, pay_period_infos,
selected_employee_name, selected_employee_name,
canSaveTimesheets,
has_timesheet_preset, has_timesheet_preset,
timesheets, timesheets,
all_current_shifts, all_current_shifts,
initial_timesheets, initial_timesheets,
federal_holidays, federal_holidays,
paid_time_off_totals,
isShowingUnsavedWarning,
nextPageNameAfterUnsaveWarning,
getCurrentFederalHolidays, getCurrentFederalHolidays,
getNextOrPreviousPayPeriod, getNextOrPreviousPayPeriod,
getPayPeriodByDateOrYearAndNumber, getPayPeriodByDateOrYearAndNumber,
getTimesheetOverviews, getTimesheetOverviews,
getTimesheetsByOptionalEmployeeEmail, getTimesheetsByOptionalEmployeeEmail,
toggleTimesheetsApprovalByEmployeeEmail, toggleTimesheetsApprovalByEmployeeEmail,
applyPreset,
getPayPeriodReport, getPayPeriodReport,
openReportDialog, openReportDialog,
closeReportDialog, closeReportDialog,
subscribeToPayPeriodObservable, subscribeToPayPeriodObservable,
unsubscribeToPayPeriodObservable, unsubscribeToPayPeriodObservable,
getPaidTimeOffTotalsWithOptionalEmployeeEmail,
}; };
}); });

View File

@ -20,14 +20,27 @@ export const getMinutes = (hours: number) => {
return minutes > 1 ? minutes.toString() : '0'; return minutes > 1 ? minutes.toString() : '0';
} }
export const getHoursMinutesStringFromHoursFloat = (hours: number): string => { export const getHoursMinutesStringFromHoursFloat = (hours: number, minutes?: number): string => {
let flat_hours = Math.floor(hours); let flatHours = Math.floor(hours);
let minutes = Math.round((hours - flat_hours) * 60); let flatMinutes = minutes ?? Math.round((hours - flatHours) * 60);
if (minutes === 60) { if (flatMinutes === 60) {
flat_hours += 1; flatHours += 1;
minutes = 0; flatMinutes = 0;
} }
return `${flat_hours}h${minutes > 1 ? ' ' + minutes : ''}` return `${flatHours}h${flatMinutes > 1 ? ' ' + flatMinutes : ''}`
}
export const getHoursMinutesBetweenTwoHHmm = (startTime: string, endTime: string): {
hours: number,
minutes: number,
} => {
const [startHours, startMinutes] = startTime.split(':');
const [endHours, endMinutes] = endTime.split(':');
return {
hours: Number(endHours) - Number(startHours),
minutes: Number(endMinutes) - Number(startMinutes),
}
} }