refactor(timesheet): more work on plugging in backend, managing expenses

This commit is contained in:
Nicolas Drolet 2025-10-31 17:03:21 -04:00
parent 18aa4c08f4
commit f0ef88a16c
12 changed files with 164 additions and 121 deletions

View File

@ -104,7 +104,6 @@ export default defineConfig((ctx) => {
config: {
notify: {
color: 'primary',
avatar: 'https://cdn.quasar.dev/img/boy-avatar.png',
},
dark: false,
},

View File

@ -14,7 +14,7 @@
$primary : #019547;
$secondary : #DAE0E7;
$accent : #AAD5C4;
$accent : #83f29f7d;
$dark-shadow-color : #00220f;

View File

@ -44,12 +44,28 @@
</template>
</q-input>
<q-card-section class="q-ma-none q-pa-none text-uppercase text-caption text-weight-medium">
<q-card-section
horizontal
class="q-mb-md q-pa-none text-uppercase text-caption text-weight-medium"
>
<q-toggle
v-model="is_remembered"
size="sm"
color="primary"
:label="$t('login.button.remember_me')"
class="col-auto"
/>
<transition
enter-active-class="animated rubberBand slow"
leave-active-class=""
mode="out-in"
>
<span
:key="is_remembered ? 'yep' : 'nope'"
class="col-auto text-weight-bold self-center q-ml-sm"
:class="is_remembered ? 'text-primary' : ''"
>{{ $t('login.button.remember_me') }}</span>
</transition>
</q-card-section>
<q-card-actions>

View File

@ -3,10 +3,10 @@
lang="ts"
>
/* eslint-disable */
import { inject, ref } from 'vue';
import { computed, inject, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useExpensesStore } from 'src/stores/expense-store';
import { empty_expense, EXPENSE_TYPE, TYPES_WITH_AMOUNT_ONLY } from 'src/modules/timesheets/models/expense.models';
import { Expense, EXPENSE_TYPE, TYPES_WITH_AMOUNT_ONLY } from 'src/modules/timesheets/models/expense.models';
import { useExpenseRules } from 'src/modules/timesheets/utils/expense.util';
import { useExpensesApi } from 'src/modules/timesheets/composables/use-expense-api';
import { useTimesheetStore } from 'src/stores/timesheet-store';
@ -18,45 +18,44 @@
const expenses_api = useExpensesApi();
const files = defineModel<File[] | null>('files');
const is_navigator_open = ref(false);
const mode = ref<'create' | 'update' | 'delete'>('create');
const COMMENT_MAX_LENGTH = 280;
const employee_email = inject<string>('employeeEmail');
const rules = useExpenseRules(t);
const background_color = computed(() => expenses_store.mode === 'update' ? 'accent' : '');
const cancelUpdateMode = () => {
expenses_store.current_expense = empty_expense;
expenses_store.initial_expense = empty_expense;
expenses_store.current_expense = new Expense;
expenses_store.initial_expense = new Expense;
}
const requestExpenseCreationOrUpdate = async () => {
if (mode.value === 'create') await expenses_api.createExpenseByEmployeeEmail(employee_email ?? '', expenses_store.current_expense?.date ?? '');
if (expenses_store.mode === 'create') await expenses_api.createExpenseByEmployeeEmail(employee_email ?? '', expenses_store.current_expense?.date ?? '');
else await expenses_api.updateExpenseByEmployeeEmail(employee_email ?? '', expenses_store.current_expense?.date ?? '');
}
</script>
<template>
<q-form
flat
v-if="!timesheet_store.timesheets?.every(timesheet => timesheet.is_approved)"
:key="expenses_store.mode"
flat
@submit.prevent="requestExpenseCreationOrUpdate"
>
<div class="text-subtitle2 q-py-sm">
{{ $t('timesheet.expense.add_expense') }}
</div>
<div
class="row justify-between rounded-5"
:class="mode === 'update' ? 'bg-accent' : ''"
>
<div class="row justify-between rounded-5">
<!-- date selection input -->
<q-input
v-model="expenses_store.current_expense.date"
dense
filled
outlined
readonly
stack-label
class="col q-px-xs"
:bg-color="background_color"
color="primary"
:label="$t('timesheet.expense.date')"
>
@ -85,6 +84,7 @@
filled
dense
class="col q-px-xs"
:bg-color="background_color"
color="primary"
emit-value
map-options
@ -105,6 +105,7 @@
clearable
color="primary"
class="col q-px-xs"
:bg-color="background_color"
:label="$t('timesheet.expense.amount')"
suffix="$"
lazy-rules="ondemand"
@ -124,6 +125,7 @@
clearable
color="primary"
class="col q-px-xs"
:bg-color="background_color"
:label="$t('timesheet.expense.mileage')"
suffix="km"
lazy-rules="ondemand"
@ -135,12 +137,13 @@
<q-input
v-model="expenses_store.current_expense.comment"
filled
color="primary"
type="text"
class="col q-px-sm"
dense
stack-label
clearable
color="primary"
type="text"
class="col q-px-sm"
:bg-color="background_color"
:counter="true"
:maxlength="COMMENT_MAX_LENGTH"
lazy-rules="ondemand"
@ -156,14 +159,15 @@
<!-- import attach file section -->
<q-file
v-model="files"
:label="$t('timesheet.expense.hints.attach_file')"
dense
filled
use-chips
multiple
stack-label
:label="$t('timesheet.expense.hints.attach_file')"
class="col"
:bg-color="background_color"
style="max-width: 300px;"
dense
>
<template #prepend>
<q-icon
@ -175,13 +179,14 @@
</q-file>
<!-- add btn section -->
<div>
<div class="col-auto column">
<q-btn
v-if="mode === 'update'"
flat
v-if="expenses_store.mode === 'update'"
push
dense
size="sm"
class="q-mt-sm q-ml-sm"
class="col q-ml-sm"
icon="cancel"
@click="cancelUpdateMode"
/>
@ -189,9 +194,10 @@
push
dense
color="primary"
icon="add"
:icon="expenses_store.mode === 'update' ? 'save' : 'add'"
:label="$q.screen.gt.sm ? (expenses_store.mode === 'update' ? $t('shared.label.update') : $t('shared.label.add')) : ''"
size="sm"
class="q-mt-sm q-ml-sm"
class="col q-mx-xs q-my-sm q-pr-sm"
type="submit"
/>
</div>

View File

@ -2,43 +2,37 @@
setup
lang="ts"
>
/* eslint-disable */
import { computed, inject, ref } from 'vue';
import { unwrapAndClone } from 'src/utils/unwrap-and-clone';
import { useExpensesApi } from 'src/modules/timesheets/composables/use-expense-api';
import { useExpensesStore } from 'src/stores/expense-store';
import { useTimesheetStore } from 'src/stores/timesheet-store';
import { getExpenseIcon } from 'src/modules/timesheets/utils/expense.util';
import { useAuthStore } from 'src/stores/auth-store';
import { CAN_APPROVE_PAY_PERIODS } from 'src/modules/shared/models/user.models';
import { empty_expense, type Expense } from 'src/modules/timesheets/models/expense.models';
import { Expense } from 'src/modules/timesheets/models/expense.models';
import { deepEqual } from 'src/utils/deep-equal';
const { expense, horizontal = false } = defineProps<{
expense: Expense;
index: number;
horizontal?: boolean;
}>();
const is_approved = defineModel<boolean>({ required: true });
const timesheet_store = useTimesheetStore();
const expenses_store = useExpensesStore();
const auth_store = useAuthStore();
const expenses_api = useExpensesApi();
const employee_email = inject<string>('employeeEmail') ?? '';
const is_approved = defineModel<boolean>({ required: true });
const is_selected = ref(false);
const refresh_key = ref(1);
const background_class = computed(() => deepEqual(expense, expenses_store.current_expense) ? 'bg-accent' : '');
const approved_class = computed(() => horizontal ? ' q-mx-xs q-pa-xs cursor-pointer' : '')
const expense_item_style = computed(() => is_approved.value ? 'border: solid 2px var(--q-primary);' : 'border: solid 2px grey;');
const is_authorized_to_approve = computed(() => CAN_APPROVE_PAY_PERIODS.includes(auth_store.user?.role ?? 'GUEST'))
const expenseItemStyle = computed(() => is_approved.value ? 'border: solid 2px var(--q-primary);' : 'border: solid 2px grey;');
// const highlightClass = computed(() => (expenses_store.mode === 'update' && is_selected) ? 'bg-accent' : '');
const approvedClass = computed(() => horizontal ? ' q-mx-xs q-pa-xs cursor-pointer' : '')
const employeeEmail = inject<string>('employeeEmail') ?? '';
const setExpenseToModify = () => {
// expenses_store.mode = 'update';
const setExpenseToUpdate = () => {
expenses_store.mode = 'update';
// if (expense.is_approved) return;
expenses_store.current_expense = expense;
expenses_store.initial_expense = unwrapAndClone(expense);
};
@ -46,16 +40,26 @@
const requestExpenseDeletion = async () => {
// expenses_store.mode = 'delete';
expenses_store.initial_expense = expense;
expenses_store.current_expense = empty_expense;
await expenses_api.deleteExpenseByEmployeeEmail(employeeEmail, expenses_store.initial_expense.date);
expenses_store.current_expense = new Expense;
await expenses_api.deleteExpenseByEmployeeEmail(employee_email, expenses_store.initial_expense.date);
}
function onExpenseClicked() {
const onExpenseClicked = () => {
if (is_authorized_to_approve.value) {
is_approved.value = !is_approved.value;
refresh_key.value += 1;
}
}
const onUpdateClicked = () => {
if (expenses_store.mode === 'update') {
expenses_store.mode = 'create';
expenses_store.current_expense = new Expense;
expenses_store.initial_expense = new Expense;
return;
}
setExpenseToUpdate();
}
</script>
<template>
@ -67,7 +71,8 @@
:key="refresh_key"
:clickable="horizontal"
class="row col-4 q-ma-xs shadow-2"
:style="expenseItemStyle + approvedClass"
:class="background_class + approved_class"
:style="expense_item_style"
@click="onExpenseClicked"
>
<q-badge
@ -104,7 +109,7 @@
</q-item-section>
<!-- amount or mileage section -->
<q-item-section class="col-auto">
<q-item-section class="col col-md-2">
<q-item-label v-if="String(expense.type).trim().toUpperCase() === 'MILEAGE'">
<template v-if="typeof expense.mileage === 'number'">
{{ expense.mileage?.toFixed(1) }} km
@ -121,9 +126,9 @@
<q-item-label
caption
lines="1"
class="text-uppercase"
>
<!-- {{ $d(new Date(expense.date), { year: 'numeric', month: 'short', day: 'numeric', weekday: 'short' }) }} -->
{{ expense.date }}
{{ $d(new Date(expense.date), { month: 'short', day: 'numeric', weekday: 'long' }) }}
</q-item-label>
</q-item-section>
@ -174,24 +179,21 @@
</q-item-label>
</q-item-section>
<q-item-section
side
class="q-pa-none"
>
<q-item-section :side="$q.screen.gt.sm">
<q-btn
push
dense
size="xs"
color="primary"
icon="edit"
class="q-mb-xs z-top"
@click.stop="setExpenseToModify"
class="z-top"
@click.stop="onUpdateClicked"
/>
</q-item-section>
<q-item-section :side="$q.screen.gt.sm">
<q-btn
push
dense
size="xs"
color="negative"
icon="close"
class="z-top"

View File

@ -35,14 +35,21 @@
<ExpenseDialogHeader />
<ExpenseDialogList />
<ExpenseDialogForm v-if="!expense_store.current_expense.is_approved" />
<q-icon
v-else
name="block"
color="negative"
size="lg"
/>
<transition
appear
enter-active-class="animated fadeInDown faster"
leave-active-class="animated fadeOutDown faster"
mode="out-in"
>
<ExpenseDialogForm v-if="!expense_store.current_expense.is_approved" />
<q-icon
v-else
name="block"
color="negative"
size="lg"
/>
</transition>
<q-separator spaced />

View File

@ -46,19 +46,27 @@
time_picker_model.value = time;
};
const onBlurShiftTypeSelect = () => {
if (shift_type_selected.value === undefined) {
shift.value.shift_id = 0;
}
}
onMounted(() => {
if (ui_store.focus_next_component) {
select_ref.value?.focus();
select_ref.value?.showPopup();
shift_type_selected.value = undefined;
ui_store.focus_next_component = false;
}
}
});
</script>
<template>
<div
v-if="shift.shift_id !== 0"
class="col row flex-center text-uppercase rounded-10"
class="row col flex-center text-uppercase rounded-10"
:class="$q.screen.lt.md ? 'q-pa-xs' : ''"
>
<!-- shift type -->
<q-select
@ -66,19 +74,22 @@
v-model="shift_type_selected"
standout="bg-blue-grey-9"
dense
options-dense
:options-dense="!ui_store.is_mobile_mode"
hide-dropdown-icon
:menu-offset="[0, 10]"
menu-anchor="bottom middle"
menu-self="top middle"
:options="options"
class="rounded-5 q-mx-xs shadow-1"
:class="ui_store.is_mobile_mode ? 'col-auto' : 'col'"
class="rounded-5 shadow-1"
:class="ui_store.is_mobile_mode ? 'col-auto q-mx-xs' : 'col q-mx-xs'"
popup-content-class="text-uppercase text-weight-bold text-center rounded-5"
popup-content-style="border: 2px solid var(--q-primary)"
@blur="onBlurShiftTypeSelect"
>
<template #selected-item="scope">
<div
class="row text-weight-bold q-ma-none q-pa-none no-wrap ellipsis"
:class="ui_store.is_mobile_mode ? 'items-center' : 'flex-center'"
:class="ui_store.is_mobile_mode ? 'items-center full-height' : 'flex-center'"
:tabindex="scope.tabindex"
>
<q-icon
@ -96,7 +107,7 @@
</template>
</q-select>
<!-- punch-in timestamp -->
<!-- punch in field -->
<q-input
v-model="shift.start_time"
dense
@ -127,7 +138,7 @@
</template>
</q-input>
<!-- punch-out timestamps -->
<!-- punch out field -->
<q-input
v-model="shift.end_time"
dense
@ -169,7 +180,7 @@
:name="shift.comment ? 'comment' : ''"
color="primary"
:size="dense ? 'xs' : 'sm'"
class="col-auto q-pa-none q-mr-xs"
class="col-auto q-pa-none"
/>
<q-btn
@ -178,7 +189,7 @@
dense
:icon="shift.comment ? 'chat' : 'chat_bubble_outline'"
:text-color="shift.comment ? 'primary' : 'grey-8'"
class="col-auto q-ma-none q-pl-md full-height"
class="col-auto q-ma-none full-height"
/>
<q-btn

View File

@ -44,7 +44,10 @@
</script>
<template>
<div :class="$q.screen.lt.md ? 'column' : 'row'">
<div
:class="$q.screen.lt.md ? 'column full-width' : 'row'"
:style="$q.screen.lt.md ? 'width: 90vw !important;' : ''"
>
<div
v-for="timesheet in timesheet_store.timesheets"
:key="timesheet.timesheet_id"

View File

@ -5,7 +5,6 @@
import ShiftList from 'src/modules/timesheets/components/shift-list.vue';
import ExpenseDialog from 'src/modules/timesheets/components/expense-dialog.vue';
import PayPeriodNavigator from 'src/modules/shared/components/pay-period-navigator.vue';
// import ShiftListLegend from 'src/modules/timesheets/components/shift-list-legend.vue';
import { useTimesheetStore } from 'src/stores/timesheet-store';
import { useTimesheetApi } from 'src/modules/timesheets/composables/use-timesheet-api';
import { useExpensesStore } from 'src/stores/expense-store';

View File

@ -4,44 +4,45 @@ export const EXPENSE_TYPE: ExpenseType[] = ['PER_DIEM', 'MILEAGE', 'EXPENSES', '
export const TYPES_WITH_MILEAGE_ONLY: Readonly<ExpenseType[]> = ['MILEAGE'];
export const TYPES_WITH_AMOUNT_ONLY: Readonly<ExpenseType[]> = ['PER_DIEM', 'EXPENSES', 'ON_CALL',];
export interface Expense {
id: number;
date: string; //YYYY-MM-DD
type: ExpenseType;
amount: number;
mileage?: number;
comment: string;
export class Expense {
id: number;
date: string; //YYYY-MM-DD
type: ExpenseType;
amount: number;
mileage?: number;
comment: string;
supervisor_comment?: string;
is_approved: boolean;
constructor() {
this.id = -1;
this.date = '';
this.type = 'EXPENSES';
this.amount = 0;
this.comment = '';
this.is_approved = false;
};
};
export const empty_expense: Expense = {
id: -1,
date: '',
type: 'EXPENSES',
amount: 0,
comment: '',
is_approved: false,
};
export const test_expenses: Expense[] = [
{
id: 201,
date: '2025-01-06',
type: 'EXPENSES',
amount: 15.5,
comment: 'Lunch receipt',
is_approved: false,
},
{
id: 202,
date: '2025-01-07',
type: 'MILEAGE',
amount: 0,
mileage: 32.4,
comment: 'Travel to client site',
is_approved: true,
},
{
id: 201,
date: '2025-01-06',
type: 'EXPENSES',
amount: 15.5,
comment: 'Lunch receipt',
is_approved: false,
},
{
id: 202,
date: '2025-01-07',
type: 'MILEAGE',
amount: 0,
mileage: 32.4,
comment: 'Travel to client site',
is_approved: true,
},
];

View File

@ -20,7 +20,7 @@ export const useAuthStore = defineStore('auth', () => {
void handleAuthMessage(event);
});
const oidc_popup = window.open(`${import.meta.env.VITE_TARGO_BACKEND_AUTH_URL}auth/v1/login`, 'authPopup', 'width=600,height=800');
const oidc_popup = window.open(`${import.meta.env.VITE_TARGO_BACKEND_URL}auth/v1/login`, 'authPopup', 'width=600,height=800');
if (!oidc_popup)
Notify.create({

View File

@ -1,7 +1,7 @@
import { ref } from "vue";
import { defineStore } from "pinia";
import { ExpensesApiError, type GenericApiError } from "src/modules/timesheets/models/expense-validation.models";
import { empty_expense, test_expenses, type Expense } from "src/modules/timesheets/models/expense.models";
import { test_expenses, Expense } from "src/modules/timesheets/models/expense.models";
import { ExpenseService } from "src/modules/timesheets/services/expense-service";
@ -9,9 +9,10 @@ import { ExpenseService } from "src/modules/timesheets/services/expense-service"
export const useExpensesStore = defineStore('expenses', () => {
const is_open = ref(false);
const is_loading = ref(false);
const mode = ref<'create' | 'update' | 'delete'>('create');
const pay_period_expenses = ref<Expense[]>(test_expenses);
const current_expense = ref<Expense>(empty_expense);
const initial_expense = ref<Expense>(empty_expense);
const current_expense = ref<Expense>(new Expense);
const initial_expense = ref<Expense>(new Expense);
const error = ref<string | null>(null);
// const setErrorFrom = (err: unknown) => {
@ -21,13 +22,10 @@ export const useExpensesStore = defineStore('expenses', () => {
const open = (): void => {
is_open.value = true;
is_loading.value = true;
error.value = null;
current_expense.value = empty_expense;
initial_expense.value = empty_expense;
// await getPayPeriodExpensesByTimesheetId(timesheet_id);
is_loading.value = false;
current_expense.value = new Expense;
initial_expense.value = new Expense;
mode.value = 'create';
}
const close = () => {
@ -80,6 +78,7 @@ export const useExpensesStore = defineStore('expenses', () => {
return {
is_open,
is_loading,
mode,
pay_period_expenses,
current_expense,
initial_expense,