Merge pull request 'feat(timesheet-approval): allow creation of expenses from employee details window' (#83) from release/nicolas/v1.1 into main

Reviewed-on: Targo/targo_frontend#83
This commit is contained in:
Nicolas 2026-02-24 11:10:17 -05:00
commit d5e26d632b
11 changed files with 173 additions and 104 deletions

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

@ -7,6 +7,7 @@ export default {
error: { error: {
NO_REPLY_RECEIVED: "encountered an error while waiting for chatbot to reply", NO_REPLY_RECEIVED: "encountered an error while waiting for chatbot to reply",
SEND_MESSAGE_FAILED: "unable to send message to chatbot", SEND_MESSAGE_FAILED: "unable to send message to chatbot",
GENERIC_RESPONSE_ERROR: "An error was encountered while waiting for the chatbot to reply, please try again.",
}, },
}, },

View File

@ -6,7 +6,8 @@ export default {
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: "Une erreur est survenu lors de la réception de la réponse du chatbot",
SEND_MESSAGE_FAILED: "Une erreur est survenu lors de l'envoi de votre message", SEND_MESSAGE_FAILED: "Une erreur est survenue lors de l'envoi de votre message",
GENERIC_RESPONSE_ERROR: "Le chatbot ne réponds pas pour le moment, veuillez réessayer.",
}, },
}, },

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,7 +98,6 @@
<!-- <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-form>
<q-card-section class="row q-pt-sm"> <q-card-section class="row q-pt-sm">
<q-separator <q-separator
@ -128,16 +139,18 @@
<q-btn <q-btn
push push
rounded rounded
type="submit"
color="accent" color="accent"
icon="img:src/assets/logo-targo-simple.svg" icon="img:src/assets/logo-targo-simple.svg"
:label="$t('login.button.employee')" :label="$t('login.button.employee')"
class="full-width row" class="full-width row"
@click="auth_api.oidcLogin" @click="onClickEmployeeConnect"
/> />
</transition> </transition>
</div> </div>
</q-slide-transition> </q-slide-transition>
</q-card-section> </q-card-section>
</q-form>
</div> </div>
</q-card> </q-card>
<div v-if="is_game_time"> <div v-if="is_game_time">

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,13 +10,18 @@
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 { 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 ======================================== // ========== state ========================================
const { t } = useI18n(); const { t } = useI18n();
const timesheetStore = useTimesheetStore(); const timesheetStore = useTimesheetStore();
const expenseStore = useExpensesStore();
const timesheetApprovalApi = useTimesheetApprovalApi(); const timesheetApprovalApi = useTimesheetApprovalApi();
const shiftApi = useShiftApi(); const shiftApi = useShiftApi();
const isDialogOpen = ref(false); const isDialogOpen = ref(false);
@ -24,11 +29,14 @@
// ========== computed ======================================== // ========== computed ========================================
const isApproved = computed(() => timesheetStore.timesheets.every(timesheet => timesheet.is_approved)); const isApproved = computed(() => timesheetStore.timesheets.every(timesheet => timesheet.is_approved));
const approveButtonLabel = computed(() => isApproved.value ? const approveButtonLabel = computed(() => isApproved.value ?
t('shared.label.unlock') : t('shared.label.unlock') :
t('shared.label.lock') t('shared.label.lock')
); );
const approveButtonIcon = computed(() => isApproved.value ? 'las la-lock' : 'las la-unlock'); const approveButtonIcon = computed(() => isApproved.value ? 'las la-lock' : 'las la-unlock');
const hasExpenses = computed(() => timesheetStore.timesheets.some(timesheet => 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))
); );
@ -49,6 +57,15 @@
const onClickSaveTimesheets = async () => { const onClickSaveTimesheets = async () => {
await shiftApi.saveShiftChanges(timesheetStore.current_pay_period_overview?.email); 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>
@ -118,6 +135,7 @@
<!-- employee pay period details using chart --> <!-- employee pay period details using chart -->
<div <div
v-if="isDialogOpen" 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'"
> >
@ -129,12 +147,51 @@
/> />
</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="timesheetStore.current_pay_period_overview?.email" :employee-email="timesheetStore.current_pay_period_overview?.email"

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,9 +64,16 @@
const requestExpenseCreationOrUpdate = async () => { const requestExpenseCreationOrUpdate = async () => {
if (file.value) if (file.value)
await expenses_api.upsertExpense(expenses_store.current_expense, employeeEmail ?? auth_store.user?.email ?? 'MISSING_EMAIL', file.value); await expenses_api.upsertExpense(
expenses_store.current_expense,
email ?? employeeEmail ?? auth_store.user?.email ?? 'MISSING_EMAIL',
file.value
);
else else
await expenses_api.upsertExpense(expenses_store.current_expense, employeeEmail ?? auth_store.user?.email ?? 'MISSING_EMAIL'); 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';

View File

@ -211,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">{{

View File

@ -19,7 +19,7 @@ export const useExpensesApi = () => {
expense.attachment_name = file.name; 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

@ -3,8 +3,8 @@ import type { BackendResponse } from "src/modules/shared/models/backend-response
import type { AttachmentPresignedURLResponse, 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;
}, },

View File

@ -36,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);