Merge pull request 'dev/nicolas/timesheet-staging-prep' (#33) from dev/nicolas/timesheet-staging-prep into main

Reviewed-on: Targo/targo_frontend#33
This commit is contained in:
matthieuh 2025-12-18 10:21:11 -05:00
commit 7c2b345600
33 changed files with 965 additions and 919 deletions

View File

@ -52,4 +52,16 @@ body.body--dark {
.q-btn--push:active::before { .q-btn--push:active::before {
border-bottom-width: 1px; border-bottom-width: 1px;
}
/* Chrome, Safari, Edge, Opera */
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Firefox */
input[type=number] {
-moz-appearance: textfield;
} }

View File

@ -176,16 +176,14 @@ export default {
timesheet: { timesheet: {
page_header: "Timesheet", page_header: "Timesheet",
apply_preset_day: "Apply schedule to day",
apply_preset_week: "Apply schedule to week",
nav_button: { nav_button: {
calendar_date_picker: "Calendar", calendar_date_picker: "Calendar",
current_week: "This week", current_week: "This week",
next_week: "Next period", next_week: "Next period",
previous_week: "Previous period", previous_week: "Previous period",
}, },
save_button: "Save",
cancel_button: "Cancel",
remote_button: "Remote work",
delete_button: "Delete",
shift: { shift: {
actions: { actions: {
add: "Add Shift", add: "Add Shift",
@ -202,7 +200,8 @@ export default {
REGULAR: "Regular", REGULAR: "Regular",
SICK: "Sick Leave", SICK: "Sick Leave",
VACATION: "Vacation", VACATION: "Vacation",
REMOTE: "Remote work", REMOTE: "Remote",
OFFICE: "Office",
}, },
fields: { fields: {
start: "Start (HH:mm)", start: "Start (HH:mm)",
@ -239,8 +238,9 @@ export default {
errors: { errors: {
INVALID_SHIFT_TIME: "In and Out shift times are reversed", INVALID_SHIFT_TIME: "In and Out shift times are reversed",
SHIFT_OVERLAP: "An overlaps occured between 2 or more shifts", SHIFT_OVERLAP: "An overlaps occured between 2 or more shifts",
SHIFT_OVERLAP_SHORT: "Overlap",
INVALID_SHIFT: "A shift contains missing or corrupted data", INVALID_SHIFT: "A shift contains missing or corrupted data",
SHIFT_TIME_REQUIRED: "Valid time required", SHIFT_TIME_REQUIRED: "Time required",
SHIFT_TYPE_REQUIRED: "Shift type required", SHIFT_TYPE_REQUIRED: "Shift type required",
SHIFT_NOT_FOUND: "Shift missing or deleted", SHIFT_NOT_FOUND: "Shift missing or deleted",
PAY_PERIOD_NOT_FOUND: "No pay period matching given dates", PAY_PERIOD_NOT_FOUND: "No pay period matching given dates",

View File

@ -177,16 +177,14 @@ export default {
timesheet: { timesheet: {
page_header: "Carte de temps", page_header: "Carte de temps",
apply_preset_day: "Appliquer horaire pour la journée",
apply_preset_week: "Appliquer horaire pour la semaine",
nav_button: { nav_button: {
calendar_date_picker: "Calendrier", calendar_date_picker: "Calendrier",
current_week: "Semaine actuelle", current_week: "Semaine actuelle",
next_week: "Prochaine période", next_week: "Prochaine période",
previous_week: "Période précédente", previous_week: "Période précédente",
}, },
save_button: "Enregistrer",
cancel_button: "Annuler",
remote_button: "Télétravail",
delete_button: "Supprimer",
shift: { shift: {
actions: { actions: {
add: "Ajouter un Quart", add: "Ajouter un Quart",
@ -204,6 +202,7 @@ export default {
SICK: "Maladie", SICK: "Maladie",
VACATION: "Vacance", VACATION: "Vacance",
REMOTE: "Télétravail", REMOTE: "Télétravail",
OFFICE: "Bureau",
}, },
fields: { fields: {
start: "Début (HH:mm)", start: "Début (HH:mm)",
@ -240,6 +239,7 @@ export default {
errors: { errors: {
INVALID_SHIFT_TIME: "Les heures d'entrée et de sortie sont inversées", INVALID_SHIFT_TIME: "Les heures d'entrée et de sortie sont inversées",
SHIFT_OVERLAP: "Il y a un chevauchement entre deux ou plusieurs quarts", SHIFT_OVERLAP: "Il y a un chevauchement entre deux ou plusieurs quarts",
SHIFT_OVERLAP_SHORT: "Chevauchement",
INVALID_SHIFT: "Un quart de travail contient des données manquantes ou corrompues", INVALID_SHIFT: "Un quart de travail contient des données manquantes ou corrompues",
SHIFT_TIME_REQUIRED: "Heure requise", SHIFT_TIME_REQUIRED: "Heure requise",
SHIFT_TYPE_REQUIRED: "Type requis", SHIFT_TYPE_REQUIRED: "Type requis",

View File

@ -24,7 +24,7 @@
auth_store.logout(); auth_store.logout();
router.push({ name: 'login' }).catch(err => { router.push({ name: 'login' }).catch(err => {
console.log('could not log you out: ', err); console.error('could not log you out: ', err);
}) })
} }
</script> </script>

View File

@ -114,7 +114,7 @@
<q-input <q-input
v-model="filters.search_bar_string" v-model="filters.search_bar_string"
standout outlined
dense dense
rounded rounded
color="accent" color="accent"

View File

@ -38,10 +38,7 @@ export const useEmployeeListApi = () => {
weekday.is_error = isShiftOverlap(weekday.shifts); weekday.is_error = isShiftOverlap(weekday.shifts);
} }
console.log('current preset: ', preset);
if (preset.weekdays.some(weekday => weekday.is_error)) { if (preset.weekdays.some(weekday => weekday.is_error)) {
console.log('overlap!');
return; return;
} }

View File

@ -10,7 +10,6 @@
const setDisplayLanguage = (locale: MessageLanguages) => { const setDisplayLanguage = (locale: MessageLanguages) => {
if (ui_store.user_preferences !== undefined) { if (ui_store.user_preferences !== undefined) {
ui_store.user_preferences.display_language = locale; ui_store.user_preferences.display_language = locale;
console.log('triggered language change: ', ui_store.user_preferences.display_language);
} }
} }
</script> </script>

View File

@ -2,7 +2,8 @@
setup setup
lang="ts" lang="ts"
> >
import { date } from 'quasar'; import { date, useQuasar } from 'quasar';
import { computed } from 'vue';
const { title, startDate = "", endDate = "" } = defineProps<{ const { title, startDate = "", endDate = "" } = defineProps<{
title: string; title: string;
@ -10,13 +11,17 @@
endDate?: string; endDate?: string;
}>(); }>();
const date_format_options = { day: 'numeric', month: 'long', year: 'numeric', }; const q = useQuasar();
const date_format_options = computed(() => q.platform.is.mobile ? { day: 'numeric', month: 'short', year: 'numeric' } : { day: 'numeric', month: 'long', year: 'numeric', });
</script> </script>
<template> <template>
<div class="column q-mt-lg text-uppercase text-center text-weight-bolder text-h4"> <div class="column text-uppercase text-center text-weight-bolder text-h4">
<span class="col">{{ $t(title) }}</span> <span
v-if="!$q.platform.is.mobile"
class="col q-mt-lg"
>{{ $t(title) }}</span>
<transition <transition
enter-active-class="animated fadeInDown" enter-active-class="animated fadeInDown"
@ -27,6 +32,7 @@
:key="startDate" :key="startDate"
v-if="startDate.length > 0" v-if="startDate.length > 0"
class="col row flex-center full-width q-py-none q-my-none" class="col row flex-center full-width q-py-none q-my-none"
:class="$q.platform.is.mobile ? 'q-mb-md' : ''"
> >
<div class="text-accent text-weight-bold text-h6"> <div class="text-accent text-weight-bold text-h6">
{{ $d(date.extractDate(startDate, 'YYYY-MM-DD'), date_format_options) }} {{ $d(date.extractDate(startDate, 'YYYY-MM-DD'), date_format_options) }}

View File

@ -33,30 +33,13 @@
emit('date-selected', value); emit('date-selected', value);
}; };
const getNextOrPreviousPayPeriod = (direction: number) => {
const pay_period = timesheet_store.pay_period;
if (!pay_period) return;
pay_period.pay_period_no += direction;
if (pay_period.pay_period_no > 26) {
pay_period.pay_period_no = 1;
pay_period.pay_year += direction;
}
if (pay_period.pay_period_no < 1) {
pay_period.pay_period_no = 26;
pay_period.pay_year += direction;
}
};
const getNextPayPeriod = () => { const getNextPayPeriod = () => {
getNextOrPreviousPayPeriod(NEXT); timesheet_store.getNextOrPreviousPayPeriod(NEXT);
emit('pressed-next-button'); emit('pressed-next-button');
} }
const getPreviousPayPeriod = () => { const getPreviousPayPeriod = () => {
getNextOrPreviousPayPeriod(PREVIOUS); timesheet_store.getNextOrPreviousPayPeriod(PREVIOUS);
emit('pressed-previous-button'); emit('pressed-previous-button');
}; };
</script> </script>

View File

@ -2,15 +2,15 @@
setup setup
lang="ts" lang="ts"
> >
import { date } from 'quasar';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { computed, ref, watch } from 'vue'; import { computed, ref } from 'vue';
import { deepEqual } from 'src/utils/deep-equal';
import { useUiStore } from 'src/stores/ui-store'; import { useUiStore } from 'src/stores/ui-store';
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 { useExpensesApi } from 'src/modules/timesheets/composables/use-expense-api'; import { useExpensesApi } from 'src/modules/timesheets/composables/use-expense-api';
import { convertToMonetaryAmount, getExpenseIcon, useExpenseRules } from 'src/modules/timesheets/utils/expense.util'; import { getExpenseIcon, useExpenseRules } from 'src/modules/timesheets/utils/expense.util';
import { type ExpenseType, TYPES_WITH_AMOUNT_ONLY } from 'src/modules/timesheets/models/expense.models'; import { Expense, type ExpenseType, TYPES_WITH_AMOUNT_ONLY } from 'src/modules/timesheets/models/expense.models';
interface ExpenseOption { interface ExpenseOption {
label: string; label: string;
@ -26,7 +26,6 @@
const files = defineModel<File[] | null>('files'); const files = defineModel<File[] | null>('files');
const is_navigator_open = ref(false); const is_navigator_open = ref(false);
const is_initial_expense = ref(true);
const COMMENT_MAX_LENGTH = 280; const COMMENT_MAX_LENGTH = 280;
const rules = useExpenseRules(t); const rules = useExpenseRules(t);
@ -35,17 +34,13 @@
const period_end_date = computed(() => timesheet_store.pay_period?.period_end.replaceAll('-', '/') ?? ''); const period_end_date = computed(() => timesheet_store.pay_period?.period_end.replaceAll('-', '/') ?? '');
const expense_options: ExpenseOption[] = [ const expense_options: ExpenseOption[] = [
{label: t('timesheet.expense.types.PER_DIEM'), value: 'PER_DIEM', icon: getExpenseIcon('PER_DIEM')}, { label: t('timesheet.expense.types.PER_DIEM'), value: 'PER_DIEM', icon: getExpenseIcon('PER_DIEM') },
{label: t('timesheet.expense.types.EXPENSES'), value: 'EXPENSES', icon: getExpenseIcon('EXPENSES')}, { label: t('timesheet.expense.types.EXPENSES'), value: 'EXPENSES', icon: getExpenseIcon('EXPENSES') },
{label: t('timesheet.expense.types.MILEAGE'), value: 'MILEAGE', icon: getExpenseIcon('MILEAGE')}, { label: t('timesheet.expense.types.MILEAGE'), value: 'MILEAGE', icon: getExpenseIcon('MILEAGE') },
{label: t('timesheet.expense.types.ON_CALL'), value: 'ON_CALL', icon: getExpenseIcon('ON_CALL')}, { label: t('timesheet.expense.types.ON_CALL'), value: 'ON_CALL', icon: getExpenseIcon('ON_CALL') },
] ]
const expense_selected = ref(expense_options.find(expense => expense.value == expenses_store.current_expense.type));
const emit = defineEmits<{ const expense_selected = ref(expense_options.find(expense => expense.value == expenses_store.current_expense.type));
'onClickUpdateCancel': [void];
'onClickSaveUpdates': [void];
}>();
const openDatePicker = () => { const openDatePicker = () => {
is_navigator_open.value = true; is_navigator_open.value = true;
@ -61,144 +56,132 @@
const requestExpenseCreationOrUpdate = async () => { const requestExpenseCreationOrUpdate = async () => {
await expenses_api.upsertExpense(expenses_store.current_expense); await expenses_api.upsertExpense(expenses_store.current_expense);
if (expenses_store.current_expense.id) {
emit('onClickSaveUpdates');
}
expenses_store.is_showing_create_form = true;
expenses_store.mode = 'create';
expenses_store.current_expense = new Expense(date.formatDate(new Date(), 'YYYY-MM-DD'));
}; };
watch(expenses_store.current_expense, () => {
is_initial_expense.value = deepEqual(expenses_store.current_expense, expenses_store.initial_expense);
});
</script> </script>
<template> <template>
<q-form <q-form
v-if="!timesheet_store.timesheets?.every(timesheet => timesheet.is_approved)" v-if="!timesheet_store.timesheets?.every(timesheet => timesheet.is_approved)"
:key="expenses_store.current_expense.id"
flat flat
@submit.prevent="requestExpenseCreationOrUpdate" @submit.prevent="requestExpenseCreationOrUpdate"
class="full-width" class="full-width q-mt-md q-px-md"
> >
<div
class="text-uppercase text-weight-medium q-pt-sm q-ma-sm"
:class="expenses_store.mode === 'create' ? 'q-px-lg' : 'invisible'"
>
{{ $t('timesheet.expense.add_expense') }}
</div>
<div <div
class="row justify-between items-start rounded-5 q-pb-sm" class="row justify-between items-start rounded-5 q-pb-sm"
:class="expenses_store.mode === 'create' ? 'q-px-lg' : ''" :class="expenses_store.mode === 'create' ? 'q-px-lg' : ''"
> >
<!-- date selection input --> <!-- date selection input -->
<q-input <div class="col q-px-xs">
v-model="expenses_store.current_expense.date" <q-input
dense v-model="expenses_store.current_expense.date"
type="date" dense
borderless standout
readonly readonly
stack-label stack-label
color="primary" color="primary"
class="col-auto q-px-xs" input-class="text-weight-medium"
input-class="text-weight-medium" input-style="font-size: 1em;"
input-style="font-size: 1em;" :label="$t('timesheet.expense.date')"
:label="$t('timesheet.expense.date')" >
> <template #prepend>
<template #prepend> <q-btn
<q-btn push
push dense
dense icon="event"
icon="event" color="accent"
color="accent" class="q-mr-sm"
class="q-mr-sm" @click="openDatePicker"
@click="openDatePicker"
/>
<q-dialog
v-model="is_navigator_open"
transition-show="jump-right"
transition-hide="jump-right"
>
<q-date
v-model="expenses_store.current_expense.date"
mask="YYYY-MM-DD"
event-color="accent"
:options="date => date >= period_start_date && date <= period_end_date"
@update:model-value="closeDatePicker"
/> />
</q-dialog>
</template>
<template #label> <q-dialog
<span class="text-weight-bold text-accent text-uppercase text-caption"> v-model="is_navigator_open"
{{ $t('timesheet.expense.date') }} transition-show="jump-right"
</span> transition-hide="jump-right"
</template> >
</q-input> <q-date
v-model="expenses_store.current_expense.date"
mask="YYYY-MM-DD"
event-color="accent"
:options="date => date >= period_start_date && date <= period_end_date"
@update:model-value="closeDatePicker"
/>
</q-dialog>
</template>
<template #label>
<span class="text-weight-bold text-accent text-uppercase text-caption">
{{ $t('timesheet.expense.date') }}
</span>
</template>
</q-input>
</div>
<!-- expenses type selection --> <!-- expenses type selection -->
<q-select <div class="col q-px-xs">
v-model="expense_selected" <q-select
standout="bg-blue-grey-9" v-model="expense_selected"
dense standout
:options="expense_options" dense
hide-dropdown-icon :options="expense_options"
stack-label hide-dropdown-icon
label-slot stack-label
class="col q-px-xs" label-slot
color="primary" color="primary"
:label="$t('timesheet.expense.type')" :label="$t('timesheet.expense.type')"
:menu-offset="[0, 10]" :menu-offset="[0, 10]"
menu-anchor="bottom middle" menu-anchor="bottom middle"
menu-self="top middle" menu-self="top middle"
popup-content-class="text-uppercase text-weight-bold text-center rounded-5" popup-content-class="text-uppercase text-weight-bold text-center rounded-5"
popup-content-style="border: 2px solid var(--q-accent)" popup-content-style="border: 2px solid var(--q-accent)"
:rules="[rules.typeRequired]" :rules="[rules.typeRequired]"
@update:model-value="option => expenses_store.current_expense.type = option.value" @update:model-value="option => expenses_store.current_expense.type = option.value"
> >
<template #label> <template #label>
<span class="text-weight-bold text-accent text-uppercase text-caption"> <span class="text-weight-bold text-accent text-uppercase text-caption">
{{ $t('timesheet.expense.type') }} {{ $t('timesheet.expense.type') }}
</span> </span>
</template> </template>
<template #selected-item="scope"> <template #selected-item="scope">
<div <div
class="row items-center text-weight-bold q-ma-none q-pa-none no-wrap ellipsis full-width" class="row items-center text-weight-bold q-ma-none q-pa-none no-wrap ellipsis full-width"
:class="ui_store.is_mobile_mode ? 'full-height' : ''" :class="ui_store.is_mobile_mode ? 'full-height' : ''"
:tabindex="scope.tabindex" :tabindex="scope.tabindex"
> >
<q-icon <q-icon
:name="scope.opt.icon" :name="scope.opt.icon"
size="xs" size="xs"
class="col-auto q-mx-xs" class="col-auto q-mx-xs"
/> />
<span <span
style="line-height: 1em;" style="line-height: 1em;"
class="col-auto ellipsis text-uppercase" class="col-auto ellipsis text-uppercase"
>{{ scope.opt.label }}</span> >{{ scope.opt.label }}</span>
</div> </div>
</template> </template>
</q-select> </q-select>
</div>
<!-- amount input --> <!-- amount input -->
<div v-if="TYPES_WITH_AMOUNT_ONLY.includes(expenses_store.current_expense?.type ?? 'EXPENSES')"> <div class="col q-px-xs">
<q-input <q-input
key="amount" v-if="TYPES_WITH_AMOUNT_ONLY.includes(expenses_store.current_expense?.type ?? 'EXPENSES')"
v-model.number="expenses_store.current_expense.amount" v-model="expenses_store.current_expense.amount"
standout="bg-blue-grey-9" standout
dense dense
label-slot label-slot
stack-label stack-label
suffix="$" suffix="$"
type="number"
color="primary" color="primary"
class="col-auto q-px-xs" input-class="text-right text-weight-medium"
input-class="text-right text-weight-bold" input-style="font-size: 1.3em;"
:input-style="'font-size: 1.2em;'"
lazy-rules="ondemand" lazy-rules="ondemand"
:rules="[rules.amountRequired]" :rules="[rules.amountRequired]"
@blur="expenses_store.current_expense.amount = convertToMonetaryAmount(expenses_store.current_expense.amount)"
> >
<template #label> <template #label>
<span class="text-weight-bold text-accent text-uppercase text-caption"> <span class="text-weight-bold text-accent text-uppercase text-caption">
@ -206,22 +189,19 @@
</span> </span>
</template> </template>
</q-input> </q-input>
</div>
<!-- mileage input -->
<div v-else>
<q-input <q-input
key="mileage" v-else
v-model.number="expenses_store.current_expense.mileage" v-model="expenses_store.current_expense.mileage"
standout="bg-blue-grey-9" standout
input-class="text-right"
dense dense
stack-label
clearable
color="primary"
class="col q-px-xs"
label-slot label-slot
stack-label
suffix="km" suffix="km"
type="number"
input-class="text-right text-weight-medium"
input-style="font-size: 1.3em;"
color="primary"
lazy-rules="ondemand" lazy-rules="ondemand"
:rules="[rules.mileageRequired]" :rules="[rules.mileageRequired]"
> >
@ -234,71 +214,63 @@
</div> </div>
<!-- employee comment input --> <!-- employee comment input -->
<q-input <div class="col q-px-xs">
v-model="expenses_store.current_expense.comment" <q-input
standout="bg-blue-grey-9" v-model="expenses_store.current_expense.comment"
dense standout
stack-label dense
label-slot stack-label
color="primary" label-slot
type="text" color="primary"
class="col q-px-sm" input-class="text-weight-medium"
:maxlength="COMMENT_MAX_LENGTH" input-style="font-size: 1.3em;"
lazy-rules="ondemand" :maxlength="COMMENT_MAX_LENGTH"
:rules="[rules.commentRequired]" lazy-rules="ondemand"
> :rules="[rules.commentRequired]"
<template #label> >
<span class="text-weight-bold text-accent text-uppercase text-caption"> <template #label>
{{ $t('timesheet.expense.employee_comment') }} <span class="text-weight-bold text-accent text-uppercase text-caption">
</span> {{ $t('timesheet.expense.employee_comment') }}
</template> </span>
</q-input> </template>
</q-input>
</div>
<!-- import attach file section --> <!-- import attach file section -->
<q-file <div class="col q-px-xs">
v-model="files" <q-file
standout="bg-blue-grey-9" v-model="files"
dense standout
use-chips dense
multiple use-chips
stack-label multiple
:label="$t('timesheet.expense.hints.attach_file')" stack-label
class="col" label-slot
style="max-width: 300px;" >
> <template #prepend>
<template #prepend> <q-icon
<q-icon name="attach_file"
name="attach_file" size="sm"
size="sm" color="accent"
color="accent" />
/> </template>
</template>
<template #label> <template #label>
<span class="text-weight-bold text-accent text-uppercase text-caption"> <span class="text-weight-bold text-accent text-uppercase text-caption">
{{ $t('timesheet.expense.hints.attach_file') }} {{ $t('timesheet.expense.hints.attach_file') }}
</span> </span>
</template> </template>
</q-file> </q-file>
</div>
</div> </div>
<div class="col row full-width items-center"> <div class="col row full-width items-center">
<q-space /> <q-space />
<q-btn
v-if="expenses_store.mode === 'update'"
flat
dense
class="col-auto q-ml-sm"
icon="clear"
color="negative"
:label="$q.screen.gt.sm ? $t('shared.label.cancel') : ''"
@click="$emit('onClickUpdateCancel')"
/>
<q-btn <q-btn
push push
:disable="is_initial_expense" :disable="expenses_store.is_save_disabled"
:color="is_initial_expense ? 'grey-5' : 'accent'" :color="expenses_store.is_save_disabled ? 'grey-5' : 'accent'"
:icon="expenses_store.mode === 'update' ? 'save' : 'upload'" :icon="expenses_store.mode === 'update' ? 'save' : 'upload'"
:label="expenses_store.mode === 'update' ? $t('shared.label.update') : $t('shared.label.add')" :label="expenses_store.mode === 'update' ? $t('shared.label.update') : $t('shared.label.add')"
class="q-px-sm " class="q-px-sm "
@ -307,4 +279,10 @@
/> />
</div> </div>
</q-form> </q-form>
</template> </template>
<style scoped>
:deep(.q-field--standout.q-field--readonly .q-field__control::before) {
border: transparent;
}
</style>

View File

@ -12,8 +12,10 @@
let expenses = 0; let expenses = 0;
let mileage = 0; let mileage = 0;
timesheet_store.timesheets.forEach(timesheet => { timesheet_store.timesheets.forEach(timesheet => {
expenses += timesheet.weekly_expenses.expenses ?? 0; expenses += timesheet.weekly_expenses.expenses;
mileage += timesheet.weekly_expenses.mileage ?? 0; expenses += timesheet.weekly_expenses.on_call;
expenses += timesheet.weekly_expenses.per_diem;
mileage += timesheet.weekly_expenses.mileage;
}); });
return { expenses, mileage }; return { expenses, mileage };
@ -22,8 +24,8 @@
<template> <template>
<div class="column items-center q-pa-none"> <div class="column items-center q-pa-none">
<div class="col row full-width"> <div class="col row full-width bg-primary">
<q-item-label class="col text-h6 text-weight-bolder text-uppercase 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') }}
</q-item-label> </q-item-label>
@ -39,8 +41,8 @@
/> />
</div> </div>
<div class="col column items-end 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-sm"> <div class="col-auto row items-center q-px-md">
<span <span
v-if="$q.screen.gt.sm" v-if="$q.screen.gt.sm"
class="col-auto text-uppercase text-weight-light text-accent q-mr-xs" class="col-auto text-uppercase text-weight-light text-accent q-mr-xs"
@ -50,7 +52,7 @@
<span <span
class="col-auto text-weight-light" class="col-auto text-weight-light"
style="font-size: 2.5em; line-height: 1em;" style="font-size: 2em; line-height: 1em;"
> >
{{ weekly_totals.expenses.toFixed(2) }} {{ weekly_totals.expenses.toFixed(2) }}
</span> </span>
@ -64,7 +66,7 @@
/> />
</div> </div>
<div class="col-auto row items-center q-px-sm"> <div class="col-auto row items-center q-px-md">
<span <span
v-if="$q.screen.gt.sm" v-if="$q.screen.gt.sm"
class="col text-uppercase text-weight-light text-accent q-mr-xs" class="col text-uppercase text-weight-light text-accent q-mr-xs"
@ -74,7 +76,7 @@
<span <span
class="col-auto text-weight-light" class="col-auto text-weight-light"
style="font-size: 2.5em; line-height: 1em;" style="font-size: 2em; line-height: 1em;"
> >
{{ weekly_totals.mileage.toFixed(1) }} {{ weekly_totals.mileage.toFixed(1) }}
</span> </span>

View File

@ -2,219 +2,152 @@
setup setup
lang="ts" lang="ts"
> >
import { date } from 'quasar';
import { computed, ref, toRaw } from 'vue';
import { unwrapAndClone } from 'src/utils/unwrap-and-clone';
import { deepEqual } from 'src/utils/deep-equal';
import { useExpensesApi } from 'src/modules/timesheets/composables/use-expense-api';
import { useExpensesStore } from 'src/stores/expense-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 { Expense } from 'src/modules/timesheets/models/expense.models';
import ExpenseDialogForm from 'src/modules/timesheets/components/expense-dialog-form.vue'; import ExpenseDialogForm from 'src/modules/timesheets/components/expense-dialog-form.vue';
const { expense, horizontal = false } = defineProps<{ import { date } from 'quasar';
expense: Expense; import { ref } from 'vue';
index: number; import { unwrapAndClone } from 'src/utils/unwrap-and-clone';
horizontal?: boolean; import { useExpensesStore } from 'src/stores/expense-store';
}>(); import { useExpensesApi } from 'src/modules/timesheets/composables/use-expense-api';
const is_approved = defineModel<boolean>({ required: true }); import { getExpenseIcon } from 'src/modules/timesheets/utils/expense.util';
import type { Expense } from 'src/modules/timesheets/models/expense.models';
const expense = defineModel<Expense>({ required: true });
const expenses_store = useExpensesStore(); const expenses_store = useExpensesStore();
const auth_store = useAuthStore();
const expenses_api = useExpensesApi(); const expenses_api = useExpensesApi();
const refresh_key = ref(1);
const background_class = computed(() => deepEqual(expense, expenses_store.current_expense) ? '' : '');
const approved_class = computed(() => expense.is_approved ? ' bg-accent text-white' : '')
const is_authorized_to_approve = computed(() => CAN_APPROVE_PAY_PERIODS.includes(auth_store.user?.role ?? 'GUEST'))
const is_showing_update_form = ref(false); const is_showing_update_form = ref(false);
const is_current_expense = computed(() => expense.id === expenses_store.current_expense.id);
const requestExpenseDeletion = async () => { const requestExpenseDeletion = async () => {
await expenses_api.deleteExpenseById(expense.id); await expenses_api.deleteExpenseById(expense.value.id);
} }
const onExpenseClicked = () => { const onClickExpenseUpdate = () => {
if (is_authorized_to_approve.value) { if (expense.value.is_approved) return;
is_approved.value = !is_approved.value;
refresh_key.value += 1;
}
}
const onUpdateClicked = () => {
if (deepEqual(expense, expenses_store.current_expense)) {
expenses_store.mode = 'create';
Object.assign(expense, toRaw(expenses_store.initial_expense))
expenses_store.current_expense = new Expense(date.formatDate(new Date(), 'YYYY-MM-DD'));
is_showing_update_form.value = false;
return;
}
expenses_store.mode = 'update'; expenses_store.mode = 'update';
expenses_store.current_expense = expense; expenses_store.current_expense = expense.value;
expenses_store.initial_expense = unwrapAndClone(expense); expenses_store.initial_expense = unwrapAndClone(expense.value);
is_showing_update_form.value = true;
}
const onSaveUpdatesClicked = () => {
is_showing_update_form.value = false;
expenses_store.mode = 'create';
expenses_store.current_expense = new Expense(date.formatDate(new Date(), 'YYYY-MM-DD'));
} }
</script> </script>
<template> <template>
<q-item <q-expansion-item
:key="refresh_key" v-model="is_showing_update_form"
:clickable="horizontal" hide-expand-icon
class="column col-4 items-center q-my-sm q-py-none shadow-3 rounded-5 bg-dark" dense
:class="background_class + approved_class" group="expenses"
@click="onExpenseClicked" class="shadow-3 rounded-5 bg-dark q-my-sm"
:class="expense.is_approved ? ' bg-accent text-white' : ''"
@before-show="onClickExpenseUpdate()"
> >
<div class="col row fit items-center"> <template #header>
<!-- avatar type icon section --> <div class="col row items-center full-width">
<q-item-section avatar> <!-- avatar type icon section -->
<q-icon <div class="col-auto">
:name="getExpenseIcon(expense.type)" <q-icon
:color="expense.is_approved ? 'white' : ($q.dark.isActive ? 'white' : 'primary')" :name="getExpenseIcon(expense.type)"
size="lg" :color="expense.is_approved ? 'white' : ($q.dark.isActive ? 'white' : 'primary')"
/> size="lg"
</q-item-section> class="q-pr-md"
/>
</div>
<!-- amount or mileage section --> <!-- amount or mileage section -->
<q-item-section class="col col-md-2 text-weight-bold"> <div class="col column">
<q-item-label v-if="expense.type === 'MILEAGE'"> <span
{{ expense.mileage?.toFixed(1) }} km class="text-weight-bolder"
</q-item-label> :class="expense.is_approved ? ' bg-accent text-white' : ''"
<q-item-label v-else> style="font-size: 1.3em;"
$ {{ expense.amount.toFixed(2) }} >
</q-item-label> {{ expense.type === 'MILEAGE' ? `${Number(expense.mileage).toFixed(1)} km` : `$
${Number(expense.amount).toFixed(2)}` }}
</span>
<!-- date label --> <!-- date label -->
<q-item-label <span
caption class="text-uppercase text-weight-light text-caption"
lines="1" :class="expense.is_approved ? ' bg-accent text-white' : ''"
class="text-uppercase text-weight-light" >
:class="approved_class" {{ $d(date.extractDate(expense.date, 'YYYY-MM-DD'), {
> month: 'short', day: 'numeric', weekday:
{{ $d(date.extractDate(expense.date, 'YYYY-MM-DD'), { 'long'
month: 'short', day: 'numeric', weekday: }) }}
'long' </span>
}) }} </div>
</q-item-label>
</q-item-section>
<q-space v-if="horizontal" /> <!-- attachment file icon -->
<div class="col row items-center justify-start">
<q-btn
push
:color="expense.is_approved ? 'white' : 'accent'"
:text-color="expense.is_approved ? 'accent' : 'white'"
class="col-auto q-px-sm q-mr-sm"
icon="attach_file"
/>
<!-- attachment file icon --> <q-item-label class="col">
<q-item-section avatar> attachment_name.jpg
<q-btn </q-item-label>
push </div>
:color="expense.is_approved ? 'white' : 'accent'"
:text-color="expense.is_approved ? 'accent' : 'white'"
class="col-auto q-mx-sm q-px-sm q-pb-sm"
icon="attach_file"
/>
</q-item-section>
<q-item-label class="col text-weight-light text-caption"> <!-- comment section -->
<span>attachment_goes_here.jpg</span> <div class="col column">
</q-item-label> <span class="col-auto text-weight-bold text-accent text-uppercase text-caption">
{{ $t('timesheet.expense.employee_comment') }}
</span>
<!-- comment section --> <span
<q-item-section class="col"
v-if="!horizontal" :class="expense.is_approved ? ' bg-accent text-white' : ''"
top style="font-size: 1.3em;"
> >
<q-item-label {{ expense.comment }}
lines="1" </span>
class="text-weight-medium text-uppercase" </div>
>
{{ $t('timesheet.expense.employee_comment') }}
</q-item-label>
<q-item-label
caption
lines="1"
:class="approved_class"
>
{{ expense.comment }}
</q-item-label>
</q-item-section>
<!-- supervisor comment section --> <!-- supervisor comment section -->
<q-item-section <div
v-if="is_authorized_to_approve"
top
>
<q-item-label
lines="1"
class="text-weight-medium text-uppercase"
>
{{ $t('timesheet.expense.supervisor_comment') }}
</q-item-label>
<q-item-label
v-if="expense.supervisor_comment" v-if="expense.supervisor_comment"
caption class="col column"
lines="2"
> >
{{ expense.supervisor_comment }} <span class="col-auto text-weight-bold text-accent text-uppercase text-caption">
</q-item-label> {{ $t('timesheet.expense.supervisor_comment') }}
</q-item-section> </span>
<span
class="col"
:class="expense.is_approved ? ' bg-accent text-white' : ''"
style="font-size: 1.3em;"
>
{{ expense.supervisor_comment }}
</span>
</div>
<q-item-section <div class="col-auto">
:key="refresh_key" <q-icon
side v-if="expense.is_approved"
:class="is_current_expense ? 'invisible' : ''" name="verified"
> color="white"
<q-btn size="lg"
flat />
dense
size="lg"
icon="edit"
color="accent"
:disable="expense.is_approved"
class="q-py-none z-top"
:class="expense.is_approved ? 'invisible no-pointer' : ''"
@click.stop="onUpdateClicked"
/>
</q-item-section>
<q-item-section <q-btn
side v-else
:class="is_current_expense ? 'invisible' : ''" flat
> dense
<q-icon size="lg"
v-if="expense.is_approved" icon="close"
name="verified" color="negative"
color="white" class="q-py-none q-my-xs"
size="lg" @click.stop="requestExpenseDeletion"
/> />
</div>
</div>
</template>
<q-btn <ExpenseDialogForm />
v-else </q-expansion-item>
flat
dense
size="lg"
icon="close"
color="negative"
class="q-py-none z-top q-my-xs"
@click.stop="requestExpenseDeletion"
/>
</q-item-section>
</div>
<q-slide-transition
@hide="expenses_store.mode === 'update' ? null : expenses_store.is_hiding_create_form = false"
:duration="200"
>
<ExpenseDialogForm
v-if="is_current_expense && expenses_store.is_hiding_create_form"
@on-click-update-cancel="onUpdateClicked"
@on-click-save-updates="onSaveUpdatesClicked"
/>
</q-slide-transition>
</q-item>
</template> </template>

View File

@ -42,19 +42,14 @@
:key="index" :key="index"
> >
<ExpenseDialogListItemMobile <ExpenseDialogListItemMobile
v-if="$q.screen.lt.md" v-if="$q.platform.is.mobile"
v-model="expense.is_approved" v-model="expenses_list[index]!"
:index="index"
:expense="expense"
:horizontal="horizontal"
/> />
<ExpenseDialogListItem <ExpenseDialogListItem
v-else v-else
v-model="expense.is_approved" v-model="expenses_list[index]!"
:index="index" :index="index"
:expense="expense"
:horizontal="horizontal"
/> />
</div> </div>
</q-list> </q-list>

View File

@ -2,25 +2,37 @@
setup setup
lang="ts" lang="ts"
> >
import { useExpensesStore } from 'src/stores/expense-store';
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 ExpenseDialogForm from 'src/modules/timesheets/components/expense-dialog-form.vue';
import ExpenseDialogFormMobile from 'src/modules/timesheets/components/mobile/expense-dialog-form-mobile.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 { date } from 'quasar';
import { useExpensesStore } from 'src/stores/expense-store';
import { Expense } from 'src/modules/timesheets/models/expense.models';
const expense_store = useExpensesStore(); const expense_store = useExpensesStore();
const { isApproved = false} = defineProps<{
isApproved?: boolean;
}>();
const onClickExpenseCreate = () => {
expense_store.mode = 'create';
expense_store.current_expense = new Expense(date.formatDate(new Date(), 'YYYY-MM-DD'));
}
</script> </script>
<template> <template>
<q-dialog <q-dialog
v-model="expense_store.is_open" v-model="expense_store.is_open"
persistent persistent
:full-width="$q.platform.is.mobile"
transition-show="jump-down" transition-show="jump-down"
transition-hide="jump-down" transition-hide="jump-down"
> >
<q-card <q-card
class="q-pa-none rounded-10 shadow-10" class="q-pa-none rounded-10 shadow-24 bg-secondary"
:class="$q.screen.lt.md ? ' bg-primary' : 'bg-secondary'"
style=" min-width: 70vw;" style=" min-width: 70vw;"
:style="$q.dark.isActive ? 'border: solid 2px var(--q-accent);' : ''" :style="$q.dark.isActive ? 'border: solid 2px var(--q-accent);' : ''"
> >
@ -29,26 +41,37 @@
</q-inner-loading> </q-inner-loading>
<q-card-section class="q-pa-none"> <q-card-section class="q-pa-none">
<!-- <q-banner
v-if="expenses_error"
dense
class="bg-red-2 col-auto text-negative q-mt-sm"
>
{{ expenses_error }}
</q-banner> -->
<ExpenseDialogHeader /> <ExpenseDialogHeader />
<ExpenseDialogList /> <ExpenseDialogList />
<q-separator v-if="$q.screen.lt.md" spaced color="accent" size="2px" class="q-mx-md" /> <q-expansion-item
v-if="!isApproved"
v-model="expense_store.is_showing_create_form"
hide-expand-icon
:dense="!$q.platform.is.mobile"
group="expenses"
@show="onClickExpenseCreate()"
header-class="bg-accent text-white"
>
<template #header>
<div class="row items-center">
<span class="col-auto text-uppercase text-weight-bold text-h6 q-ml-lg q-mr-sm">
{{ $t('timesheet.expense.add_expense') }}
</span>
<q-icon
v-if="expense_store.mode !== 'create'"
name="las la-plus-square"
size="md"
class="col-auto"
/>
</div>
</template>
<q-slide-transition @hide="expense_store.is_hiding_create_form = true" :duration="200"> <ExpenseDialogFormMobile v-if="$q.platform.is.mobile" />
<div v-if="!expense_store.current_expense.is_approved && expense_store.mode !== 'update' && expense_store.is_hiding_create_form === false">
<ExpenseDialogFormMobile v-if="$q.screen.lt.md" /> <ExpenseDialogForm v-else />
<ExpenseDialogForm v-else/> </q-expansion-item>
</div>
</q-slide-transition>
</q-card-section> </q-card-section>
</q-card> </q-card>
</q-dialog> </q-dialog>

View File

@ -8,7 +8,7 @@
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 { useExpensesApi } from 'src/modules/timesheets/composables/use-expense-api'; import { useExpensesApi } from 'src/modules/timesheets/composables/use-expense-api';
import { convertToMonetaryAmount, getExpenseIcon, useExpenseRules } from 'src/modules/timesheets/utils/expense.util'; import { getExpenseIcon, useExpenseRules } from 'src/modules/timesheets/utils/expense.util';
import { type ExpenseType, TYPES_WITH_AMOUNT_ONLY } from 'src/modules/timesheets/models/expense.models'; import { type ExpenseType, TYPES_WITH_AMOUNT_ONLY } from 'src/modules/timesheets/models/expense.models';
interface ExpenseOption { interface ExpenseOption {
@ -51,31 +51,18 @@
const requestExpenseCreationOrUpdate = async () => { const requestExpenseCreationOrUpdate = async () => {
await expenses_api.upsertExpense(expenses_store.current_expense); await expenses_api.upsertExpense(expenses_store.current_expense);
}; };
defineEmits<{
'onClickUpdateCancel': [void];
}>();
</script> </script>
<template> <template>
<q-form <q-form
v-if="!timesheet_store.timesheets?.every(timesheet => timesheet.is_approved)" v-if="!timesheet_store.timesheets?.every(timesheet => timesheet.is_approved)"
:key="expenses_store.current_expense.id"
flat flat
@submit.prevent="requestExpenseCreationOrUpdate" @submit.prevent="requestExpenseCreationOrUpdate"
class="column full-width" class="column full-width"
> >
<!-- header -->
<div
class="col text-uppercase text-weight-medium text-h6 q-ma-xs"
:class="expenses_store.mode === 'create' ? 'q-px-md' : 'invisible'"
>
{{ $t('timesheet.expense.add_expense') }}
</div>
<div <div
class="col column items-start rounded-5 q-pb-sm" class="col column items-start rounded-5 q-pb-sm"
:class="expenses_store.mode === 'create' ? 'q-px-md' : ''" :class="expenses_store.is_showing_create_form ? 'q-px-md' : 'q-px-sm'"
> >
<!-- date and type row --> <!-- date and type row -->
<div class="col row q-my-xs full-width"> <div class="col row q-my-xs full-width">
@ -83,7 +70,6 @@
<q-input <q-input
v-model="expenses_store.current_expense.date" v-model="expenses_store.current_expense.date"
dense dense
type="date"
outlined outlined
readonly readonly
stack-label stack-label
@ -189,7 +175,6 @@
:input-style="'font-size: 1.2em;'" :input-style="'font-size: 1.2em;'"
lazy-rules="ondemand" lazy-rules="ondemand"
:rules="[rules.amountRequired]" :rules="[rules.amountRequired]"
@blur="expenses_store.current_expense.amount = convertToMonetaryAmount(expenses_store.current_expense.amount)"
> >
<template #label> <template #label>
<span class="text-weight-bold text-accent text-uppercase text-caption"> <span class="text-weight-bold text-accent text-uppercase text-caption">
@ -287,27 +272,15 @@
</template> </template>
</q-file> </q-file>
</div> </div>
<div class="col row full-width items-center">
<q-space />
<q-btn
v-if="expenses_store.mode === 'update'"
flat
dense
class="col-auto"
icon="clear"
color="negative"
:label="$q.screen.gt.sm ? $t('shared.label.cancel') : ''"
@click="$emit('onClickUpdateCancel')"
/>
<div class="col row full-width items-center" :class="expenses_store.mode === 'create' ? 'q-px-md q-py-xs' : ''">
<q-btn <q-btn
push push
color="accent" color="accent"
:icon="expenses_store.mode === 'update' ? 'save' : 'upload'" :icon="expenses_store.mode === 'update' ? 'save' : 'upload'"
:label="expenses_store.mode === 'update' ? $t('shared.label.update') : $t('shared.label.add')" :label="expenses_store.mode === 'update' ? $t('shared.label.update') : $t('shared.label.add')"
class="q-px-sm" class="q-px-sm full-width"
:class="expenses_store.mode === 'create' ? 'q-mr-md q-mb-md' : 'q-mb-sm q-ml-lg'" :class="expenses_store.mode === 'create' ? '' : 'q-mb-sm'"
type="submit" type="submit"
/> />
</div> </div>

View File

@ -2,72 +2,48 @@
setup setup
lang="ts" lang="ts"
> >
/* eslint-disable */
import { date } from 'quasar'; import { date } from 'quasar';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { unwrapAndClone } from 'src/utils/unwrap-and-clone'; import { unwrapAndClone } from 'src/utils/unwrap-and-clone';
import { deepEqual } from 'src/utils/deep-equal';
import { useExpensesApi } from 'src/modules/timesheets/composables/use-expense-api'; import { useExpensesApi } from 'src/modules/timesheets/composables/use-expense-api';
import { useExpensesStore } from 'src/stores/expense-store'; import { useExpensesStore } from 'src/stores/expense-store';
import { getExpenseIcon } from 'src/modules/timesheets/utils/expense.util'; import { getExpenseIcon } from 'src/modules/timesheets/utils/expense.util';
import { Expense } from 'src/modules/timesheets/models/expense.models'; import type { Expense } from 'src/modules/timesheets/models/expense.models';
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';
const { expense, horizontal = false } = defineProps<{ const expense = defineModel<Expense>({ required: true })
expense: Expense;
index: number;
horizontal?: boolean;
}>();
const expenses_store = useExpensesStore(); const expenses_store = useExpensesStore();
const expenses_api = useExpensesApi(); const expenses_api = useExpensesApi();
const refresh_key = ref(1); const approved_class = computed(() => expense.value.is_approved ? ' bg-accent text-white' : '')
const background_class = computed(() => deepEqual(expense, expenses_store.current_expense) ? '' : '');
const approved_class = computed(() => expense.is_approved ? ' bg-accent text-white' : '')
const is_showing_update_form = ref(false); const is_showing_update_form = ref(false);
const requestExpenseDeletion = async () => { const requestExpenseDeletion = async () => {
await expenses_api.deleteExpenseById(expense.id); await expenses_api.deleteExpenseById(expense.value.id);
} }
const onUpdateClicked = () => { const onUpdateClicked = () => {
if (expense.is_approved) return; if (expense.value.is_approved) return;
if (deepEqual(expense, expenses_store.current_expense)) {
expenses_store.mode = 'create';
expenses_store.current_expense = new Expense(date.formatDate(new Date(), 'YYYY-MM-DD'));
is_showing_update_form.value = false;
return;
}
expenses_store.mode = 'update'; expenses_store.mode = 'update';
expenses_store.current_expense = expense; expenses_store.current_expense = expense.value;
expenses_store.initial_expense = unwrapAndClone(expense); expenses_store.initial_expense = unwrapAndClone(expense.value);
is_showing_update_form.value = true;
} }
</script> </script>
<template> <template>
<div class="column bg-dark rounded-5 q-my-sm full-width"> <div class="column bg-dark shadow-5 rounded-5 q-my-sm full-width">
<q-slide-item <q-expansion-item
right-color="negative" v-model="is_showing_update_form"
class="rounded-5 bg-dark full-width" hide-expand-icon
@right="requestExpenseDeletion" dense
group="expenses"
:class="expense.is_approved ? ' bg-accent text-white' : ''"
@before-show="onUpdateClicked()"
> >
<template <template #header>
#right
v-if="$q.screen.lt.md && !expenses_store.is_hiding_create_form && !expense.is_approved"
>
<q-icon name="delete" />
</template>
<q-item
:key="refresh_key"
clickable
class="row q-py-none q-pa-xs rounded-5 full-width"
:class="background_class + approved_class"
@click="onUpdateClicked"
>
<div class="column col"> <div class="column col">
<!-- date label --> <!-- date label -->
<div class="col-auto row items-center q-pl-xs"> <div class="col-auto row items-center q-pl-xs">
@ -94,22 +70,21 @@
:name="getExpenseIcon(expense.type)" :name="getExpenseIcon(expense.type)"
:color="expense.is_approved ? 'white' : ($q.dark.isActive ? 'white' : 'primary')" :color="expense.is_approved ? 'white' : ($q.dark.isActive ? 'white' : 'primary')"
size="lg" size="lg"
class="col-auto"
/> />
<!-- amount or mileage section --> <!-- amount or mileage section -->
<q-item-section class="col text-weight-bold text-h6"> <div class="col text-weight-bold text-h6">
<q-item-label v-if="expense.type === 'MILEAGE'"> <q-item-label v-if="expense.type === 'MILEAGE'">
{{ expense.mileage?.toFixed(1) }} km {{ expense.mileage?.toFixed(1) }} km
</q-item-label> </q-item-label>
<q-item-label v-else> <q-item-label v-else>
$ {{ expense.amount.toFixed(2) }} $ {{ expense.amount.toFixed(2) }}
</q-item-label> </q-item-label>
</q-item-section> </div>
<q-space v-if="horizontal" />
<!-- attachment file icon --> <!-- attachment file icon -->
<q-item-section avatar> <div class="col-auto q-px-xs">
<q-btn <q-btn
push push
:color="expense.is_approved ? 'white' : 'accent'" :color="expense.is_approved ? 'white' : 'accent'"
@ -117,34 +92,24 @@
class="col-auto q-mx-sm q-px-sm q-pb-sm" class="col-auto q-mx-sm q-px-sm q-pb-sm"
icon="attach_file" icon="attach_file"
/> />
</q-item-section> </div>
<div class="col-auto">
<q-icon
v-if="expense.is_approved"
name="verified"
color="white"
size="lg"
class="full-height"
/>
</div>
</div> </div>
</div> </div>
</template>
<div <div class="q-px-sm">
class="col-auto q-px-sm" <ExpenseDialogFormMobile />
:class="expense.is_approved ? '' : 'invisible'" </div>
> </q-expansion-item>
<q-icon
v-if="expense.is_approved"
name="verified"
color="white"
size="lg"
class="full-height"
/>
</div>
</q-item>
</q-slide-item>
<q-slide-transition
@hide="expenses_store.is_hiding_create_form = false"
:duration="200"
>
<ExpenseDialogFormMobile
v-if="is_showing_update_form && expenses_store.is_hiding_create_form"
class="q-mt-sm q-pa-sm"
@on-click-update-cancel="onUpdateClicked"
/>
</q-slide-transition>
</div> </div>
</template> </template>

View File

@ -8,23 +8,13 @@
import { QSelect, QInput } from 'quasar'; import { QSelect, QInput } from 'quasar';
import { Shift, type ShiftOption } from 'src/modules/timesheets/models/shift.models'; import { Shift, type ShiftOption } from 'src/modules/timesheets/models/shift.models';
import { useUiStore } from 'src/stores/ui-store'; import { useUiStore } from 'src/stores/ui-store';
import { useShiftRules } from 'src/modules/timesheets/utils/shift.util'; import { useShiftRules, SHIFT_OPTIONS } from 'src/modules/timesheets/utils/shift.util';
let timer: NodeJS.Timeout; let timer: NodeJS.Timeout;
const { t } = useI18n(); const { t } = useI18n();
const ui_store = useUiStore(); const ui_store = useUiStore();
const shift_rules = useShiftRules(t('timesheet.errors.SHIFT_TIME_REQUIRED'),);
const COMMENT_LENGTH_MAX = 280; const COMMENT_LENGTH_MAX = 280;
const SHIFT_OPTIONS: ShiftOption[] = [
{ label: t('timesheet.shift.types.REGULAR'), value: 'REGULAR', icon: 'wb_sunny', icon_color: 'blue-grey-3' },
{ label: t('timesheet.shift.types.EVENING'), value: 'EVENING', icon: 'bedtime', icon_color: 'indigo-8' },
{ label: t('timesheet.shift.types.EMERGENCY'), value: 'EMERGENCY', icon: 'ring_volume', icon_color: 'red-8' },
{ label: t('timesheet.shift.types.VACATION'), value: 'VACATION', icon: 'beach_access', icon_color: 'yellow-8' },
{ label: t('timesheet.shift.types.HOLIDAY'), value: 'HOLIDAY', icon: 'forest', icon_color: 'green-8' },
{ label: t('timesheet.shift.types.SICK'), value: 'SICK', icon: 'medication_liquid', icon_color: 'light-blue-6' },
];
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 shift_type_selected = ref(SHIFT_OPTIONS.find(option => option.value == shift.value.type));
@ -32,15 +22,19 @@
const start_time_ref = useTemplateRef<QInput>('start_time'); const start_time_ref = useTemplateRef<QInput>('start_time');
const end_time_ref = useTemplateRef<QInput>('end_time'); const end_time_ref = useTemplateRef<QInput>('end_time');
const { dense = false, hasShiftAfter = false, isTimesheetApproved = false } = defineProps<{ const { dayShifts = [], dense = false, hasShiftAfter = false, isTimesheetApproved = false } = defineProps<{
dayShifts: Shift[];
dense?: boolean; dense?: boolean;
hasShiftAfter?: boolean; hasShiftAfter?: boolean;
isTimesheetApproved?: boolean; isTimesheetApproved?: boolean;
}>(); }>();
const shift_rules = useShiftRules(t('timesheet.errors.SHIFT_TIME_REQUIRED'), t('timesheet.errors.SHIFT_OVERLAP_SHORT'), dayShifts);
const emit = defineEmits<{ const emit = defineEmits<{
'saveComment': [comment: string, shift_id: number]; 'saveComment': [comment: string, shift_id: number];
'requestDelete': [void]; 'requestDelete': [void];
'onTimeFieldBlur': [void];
}>(); }>();
const onBlurShiftTypeSelect = () => { const onBlurShiftTypeSelect = () => {
@ -98,7 +92,7 @@
<div :class="ui_store.is_mobile_mode ? 'column' : 'row'"> <div :class="ui_store.is_mobile_mode ? 'column' : 'row'">
<div <div
class="row items-start text-uppercase rounded-5" class="row items-center text-uppercase rounded-5"
:class="ui_store.is_mobile_mode ? 'col q-mb-xs q-px-xs' : 'col-4'" :class="ui_store.is_mobile_mode ? 'col q-mb-xs q-px-xs' : 'col-4'"
> >
<!-- mobile comment button --> <!-- mobile comment button -->
@ -168,7 +162,7 @@
menu-anchor="bottom middle" menu-anchor="bottom middle"
menu-self="top middle" menu-self="top middle"
:options="SHIFT_OPTIONS" :options="SHIFT_OPTIONS"
class="col rounded-5 q-mx-xs bg-dark q-pt-xs" class="col rounded-5 q-mx-xs bg-dark"
:class="!shift.is_approved && !isTimesheetApproved ? '' : 'inset-shadow'" :class="!shift.is_approved && !isTimesheetApproved ? '' : 'inset-shadow'"
popup-content-class="text-uppercase text-weight-bold text-center rounded-5" popup-content-class="text-uppercase text-weight-bold text-center rounded-5"
:style="shift.is_approved ? 'background-color: #0a7d32 !important;' : ''" :style="shift.is_approved ? 'background-color: #0a7d32 !important;' : ''"
@ -178,23 +172,68 @@
> >
<template #selected-item="scope"> <template #selected-item="scope">
<div <div
class="row flex-center text-weight-bold q-ma-none q-pa-none no-wrap ellipsis full-width" class="row items-center text-weight-bold q-ma-none q-pa-none no-wrap ellipsis full-width"
:class="ui_store.is_mobile_mode ? 'items-center full-height' : 'flex-center'" :class="ui_store.is_mobile_mode ? 'full-height' : ''"
:tabindex="scope.tabindex" :tabindex="scope.tabindex"
> >
<q-icon <q-icon
:name="scope.opt.icon" :name="scope.opt.icon"
:color="scope.opt.icon_color" :color="scope.opt.icon_color"
size="sm" size="sm"
class="col-auto q-mx-xs" class="col-auto"
:class="shift.is_approved ? 'q-mx-xs': 'q-mr-xs'"
/> />
<span <span
style="line-height: 0.9em;" style="line-height: 1.2em;"
class="col-auto ellipsis" class="col-auto ellipsis"
:class="!shift.is_approved ? '' : 'text-white'" :class="!shift.is_approved ? '' : 'text-white'"
>{{ scope.opt.label }}</span> >
{{ $t(scope.opt.label) }}
</span>
</div> </div>
</template> </template>
<template #after>
<q-icon
v-if="shift.is_approved"
:name="shift.is_remote ? 'las la-laptop' : 'las la-building'"
size="1.2em"
color="white"
class="q-mr-sm"
>
<q-tooltip
anchor="top middle"
self="bottom middle"
:offset="[0, 10]"
class="text-uppercase text-weight-bold text-white bg-primary"
>
{{ shift.is_remote ? $t('timesheet.shift.types.REMOTE') :
$t('timesheet.shift.types.OFFICE') }}
</q-tooltip>
</q-icon>
<q-toggle
v-else
v-model="shift.is_remote"
:disable="shift.is_approved"
dense
keep-color
size="3em"
color="accent"
icon="las la-building"
checked-icon="las la-laptop"
>
<q-tooltip
anchor="top middle"
self="bottom middle"
:offset="[0, 10]"
class="text-uppercase text-weight-medium text-white bg-accent"
>
{{ shift.is_remote ? $t('timesheet.shift.types.REMOTE') :
$t('timesheet.shift.types.OFFICE') }}
</q-tooltip>
</q-toggle>
</template>
</q-select> </q-select>
</div> </div>
@ -212,13 +251,14 @@
lazy-rules lazy-rules
no-error-icon no-error-icon
hide-bottom-space hide-bottom-space
:rules="[shift_rules.isTimeRequired]" :rules="[shift_rules.isTimeRequiredRule, shift_rules.isShiftOverlapRule]"
:label-color="!shift.is_approved ? 'accent' : 'white'" :label-color="!shift.is_approved ? 'accent' : 'white'"
class="col rounded-5 bg-dark" class="col rounded-5 bg-dark"
:class="(shift.id === -2 ? 'bg-negative ' : ' ') + (ui_store.is_mobile_mode ? 'q-mr-xs ' : 'q-mx-xs ') + (!shift.is_approved && !isTimesheetApproved ? '' : 'cursor-not-allowed inset-shadow')" :class="(shift.id === -2 ? 'bg-negative ' : ' ') + (ui_store.is_mobile_mode ? 'q-mr-xs ' : 'q-mx-xs ') + (!shift.is_approved && !isTimesheetApproved ? '' : 'cursor-not-allowed inset-shadow')"
:input-class="'text-weight-medium ' + (shift.id === -2 ? 'text-white ' : ' ') + (shift.is_approved ? 'text-white cursor-not-allowed q-px-sm' : '')" :input-class="'text-weight-medium ' + (shift.id === -2 ? 'text-white ' : ' ') + (shift.is_approved ? 'text-white cursor-not-allowed q-px-sm' : '')"
input-style="font-size: 1.2em;" input-style="font-size: 1.2em;"
:style="shift.is_approved ? 'background-color: #0a7d32 !important;' : ''" :style="shift.is_approved ? 'background-color: #0a7d32 !important;' : ''"
@blur="emit('onTimeFieldBlur')"
> >
<template #label> <template #label>
<span <span
@ -242,13 +282,14 @@
lazy-rules lazy-rules
no-error-icon no-error-icon
hide-bottom-space hide-bottom-space
:rules="[shift_rules.isTimeRequired]" :rules="[shift_rules.isTimeRequiredRule, shift_rules.isShiftOverlapRule]"
:label-color="!shift.is_approved ? 'accent' : 'white'" :label-color="!shift.is_approved ? 'accent' : 'white'"
:input-class="'text-weight-medium ' + (shift.id === -2 ? 'text-white ' : ' ') + (shift.is_approved ? 'text-white cursor-not-allowed q-px-sm' : '')" :input-class="'text-weight-medium ' + (shift.id === -2 ? 'text-white ' : ' ') + (shift.is_approved ? 'text-white cursor-not-allowed q-px-sm' : '')"
input-style="font-size: 1.2em;" input-style="font-size: 1.2em;"
class="col rounded-5 bg-dark" class="col rounded-5 bg-dark"
:class="(shift.id === -2 ? 'bg-negative ' : ' ') + (ui_store.is_mobile_mode ? 'q-mr-xs ' : 'q-mx-xs ') + (shift.is_approved ? 'cursor-not-allowed q-px-xs transparent inset-shadow' : (isTimesheetApproved ? 'inset-shadow' : ''))" :class="(shift.id === -2 ? 'bg-negative ' : ' ') + (ui_store.is_mobile_mode ? 'q-mr-xs ' : 'q-mx-xs ') + (shift.is_approved ? 'cursor-not-allowed q-px-xs transparent inset-shadow' : (isTimesheetApproved ? 'inset-shadow' : ''))"
:style="shift.is_approved ? 'background-color: #0a7d32 !important;' : ''" :style="shift.is_approved ? 'background-color: #0a7d32 !important;' : ''"
@blur="emit('onTimeFieldBlur')"
> >
<template #label> <template #label>
<span <span
@ -260,26 +301,24 @@
</q-input> </q-input>
<!-- comment and delete buttons --> <!-- comment and delete buttons -->
<div :class="ui_store.is_mobile_mode ? 'col-12 row' : 'col-auto self-start'"> <div class="row full-height" :class="ui_store.is_mobile_mode ? 'col-12' : 'col-auto flex-center'">
<q-icon
v-if="shift.type && dense"
:name="shift.comment ? 'comment' : ''"
color="primary"
:size="dense ? 'xs' : 'sm'"
class="col"
/>
<!-- desktop comment button --> <!-- desktop comment button -->
<q-btn <q-btn
v-else-if="!ui_store.is_mobile_mode" v-if="!ui_store.is_mobile_mode"
push push
dense dense
:color="shift.is_approved ? 'accent' : 'dark'" :color="shift.is_approved ? 'white' : 'accent'"
:icon="shift.comment ? 'chat' : 'chat_bubble_outline'" :icon="shift.comment ? 'chat' : 'chat_bubble_outline'"
:text-color="shift.is_approved ? '' : (shift.comment ? 'accent' : 'grey-5')" :text-color="shift.is_approved ? 'accent' : 'white'"
class="col" class="col"
:class="ui_store.is_mobile_mode ? 'q-mt-xs bg-dark' : ''" :class="ui_store.is_mobile_mode ? 'q-mt-xs bg-dark' : ''"
> >
<q-badge
v-if="shift.comment"
floating
rounded
color="negative"
/>
<q-popup-edit <q-popup-edit
v-model="shift.comment" v-model="shift.comment"
:title="$t('timesheet.shift.fields.header_comment')" :title="$t('timesheet.shift.fields.header_comment')"
@ -327,23 +366,18 @@
</q-btn> </q-btn>
<q-btn <q-btn
v-if="!ui_store.is_mobile_mode" v-if="!ui_store.is_mobile_mode && !shift.is_approved"
flat flat
dense dense
:disable="shift.is_approved" :disable="shift.is_approved"
tabindex="-1" tabindex="-1"
icon="cancel" icon="las la-trash"
text-color="negative" text-color="negative"
class="col" class="col"
size="1.2em"
:class="shift.is_approved ? 'invisible' : ''" :class="shift.is_approved ? 'invisible' : ''"
@click="$emit('requestDelete')" @click="$emit('requestDelete')"
> >
<q-badge
v-if="!shift.is_approved"
color="white"
class="absolute"
style="z-index: -1;"
/>
</q-btn> </q-btn>
</div> </div>
</div> </div>

View File

@ -3,18 +3,26 @@
lang="ts" lang="ts"
> >
import ShiftListDayRow from 'src/modules/timesheets/components/shift-list-day-row.vue'; import ShiftListDayRow from 'src/modules/timesheets/components/shift-list-day-row.vue';
import { ref } 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 type { Shift } from 'src/modules/timesheets/models/shift.models'; import type { Shift } from 'src/modules/timesheets/models/shift.models';
import type { TimesheetDay } from 'src/modules/timesheets/models/timesheet.models'; import type { TimesheetDay } from 'src/modules/timesheets/models/timesheet.models';
const shift_api = useShiftApi(); const shift_api = useShiftApi();
const timesheet_api = useTimesheetApi();
const { day, dense = false, approved = false } = defineProps<{ const { day, dense = false, approved = false } = defineProps<{
timesheetId: number;
weekDayIndex: number;
day: TimesheetDay; day: TimesheetDay;
dense?: boolean; dense?: boolean;
approved?: boolean; approved?: boolean;
}>(); }>();
const preset_mouseover = ref(false);
const emit = defineEmits<{ const emit = defineEmits<{
'deleteUnsavedShift': [void]; 'deleteUnsavedShift': [void];
}>(); }>();
@ -30,15 +38,46 @@
</script> </script>
<template> <template>
<div class="column justify-center q-py-xs" :class="approved ? '' : ''"> <div
<ShiftListDayRow class="column justify-center q-py-xs"
v-for="shift, shift_index in day.shifts" :class="approved ? '' : ''"
:key="shift_index" @mouseenter="preset_mouseover = true"
v-model:shift="day.shifts[shift_index]!" @mouseleave="preset_mouseover = false"
:is-timesheet-approved="approved" >
:dense="dense" <!-- Button to apply preset to day -->
:has-shift-after="shift_index < day.shifts.length - 1" <transition
@request-delete="deleteCurrentShift(shift)" appear
/> enter-active-class="animated zoomIn fast"
leave-active-class="animated zoomOut fast"
>
<q-btn
v-if="!$q.platform.is.mobile && day.shifts.length < 1 && preset_mouseover"
:disable="day.shifts.length > 0"
flat
dense
size="lg"
:label="$t('timesheet.apply_preset_day')"
class="text-uppercase text-weight-bold text-accent q-mx-lg q-py-none rounded-5"
style="opacity: 0.6;"
@click.stop="timesheet_api.applyPreset(timesheetId, weekDayIndex, day.date)"
>
<q-icon
name="las la-calendar-day"
color="accent"
size="md"
/>
</q-btn>
</transition>
<ShiftListDayRow
v-for="shift, shift_index in day.shifts"
:key="shift_index"
v-model:shift="day.shifts[shift_index]!"
:day-shifts="day.shifts"
:is-timesheet-approved="approved"
:dense="dense"
:has-shift-after="shift_index < day.shifts.length - 1"
@request-delete="deleteCurrentShift(shift)"
/>
</div> </div>
</template> </template>

View File

@ -2,21 +2,29 @@
setup setup
lang="ts" lang="ts"
> >
import { computed } from 'vue'; import ShiftListDay from 'src/modules/timesheets/components/shift-list-day.vue';
import ShiftListDateWidget from 'src/modules/timesheets/components/shift-list-date-widget.vue';
import { date } from 'quasar'; import { date } from 'quasar';
import { computed, ref } from 'vue';
import { useUiStore } from 'src/stores/ui-store'; import { useUiStore } from 'src/stores/ui-store';
import { useTimesheetStore } from 'src/stores/timesheet-store'; import { useTimesheetStore } from 'src/stores/timesheet-store';
import { Shift } from 'src/modules/timesheets/models/shift.models'; import { Shift } from 'src/modules/timesheets/models/shift.models';
import ShiftListDay from 'src/modules/timesheets/components/shift-list-day.vue'; import { useTimesheetApi } from 'src/modules/timesheets/composables/use-timesheet-api';
import ShiftListDateWidget from 'src/modules/timesheets/components/shift-list-date-widget.vue'; import type { QScrollArea } from 'quasar';
import type { TimesheetDay } from 'src/modules/timesheets/models/timesheet.models'; import type { TimesheetDay } from 'src/modules/timesheets/models/timesheet.models';
const { extractDate } = date; const { extractDate } = date;
const ui_store = useUiStore(); const ui_store = useUiStore();
const timesheet_store = useTimesheetStore(); const timesheet_store = useTimesheetStore();
const timesheet_api = useTimesheetApi();
const animation_style = computed(() => ui_store.is_mobile_mode ? 'fadeInLeft' : 'fadeInDown' ); const mobile_animation_direction = ref('fadeInLeft');
const animation_style = computed(() => ui_store.is_mobile_mode ? mobile_animation_direction.value : 'fadeInDown');
const timesheet_page = ref<QScrollArea | null>(null);
const scroll_y = computed(() => timesheet_page.value?.getScrollPosition().top ?? 0)
const addNewShift = (day_shifts: Shift[], date: string, timesheet_id: number) => { const addNewShift = (day_shifts: Shift[], date: string, timesheet_id: number) => {
ui_store.focus_next_component = true; ui_store.focus_next_component = true;
@ -38,141 +46,233 @@
if (day.shifts.length < 1) return false; if (day.shifts.length < 1) return false;
return day.shifts.every(shift => shift.is_approved === true); return day.shifts.every(shift => shift.is_approved === true);
} }
const handleSwipe = async (direction: 'left' | 'up' | 'down' | 'right' | undefined, distance: {x?: number, y?: number}) => {
mobile_animation_direction.value = direction === 'left' ? 'fadeInRight' : 'fadeInLeft';
if (distance.x && Math.abs(distance.x) > 10 ) {
await timesheet_api.getTimesheetsBySwiping( direction === 'left' ? 1 : -1 )
}
}
</script> </script>
<template> <template>
<div <div class="col column fit relative-position" v-touch-swipe="value => handleSwipe(value.direction, value.distance ?? {x: 0, y: 0})">
:class="$q.screen.lt.md ? 'column full-width' : 'row'" <q-scroll-area
> ref="timesheet_page"
<div :horizontal-offset="[0, 3]"
v-for="timesheet, timesheet_index in timesheet_store.timesheets" class="absolute-full hide-scrollbar q-mt-sm"
:key="timesheet.timesheet_id" :thumb-style="{ opacity: '0' }"
class="col column" :bar-style="{ opacity: '0' }"
style="min-height: 50vh;"
> >
<transition-group <!-- Show if no timesheets found (further than one month from present) -->
appear <div
:enter-active-class="`animated ${animation_style}`" v-if="timesheet_store.timesheets.length < 1"
class="col-auto column flex-center fit q-py-lg"
style="min-height: 20vh;"
>
<span class="text-uppercase text-weight-bolder text-center">{{ $t('shared.error.no_data_found')
}}</span>
<q-icon
name="las la-calendar"
color="accent"
size="10em"
class="absolute"
style="opacity: 0.2;"
/>
</div>
<!-- Else show timesheets if found -->
<div
v-else
class="col fit"
:class="$q.platform.is.mobile ? 'column' : 'row'"
> >
<div <div
v-for="day, day_index in timesheet.days" v-for="timesheet, timesheet_index of timesheet_store.timesheets"
:key="day.date" :key="timesheet.timesheet_id"
class="col-auto row rounded-10 q-ma-sm shadow-10" class="col fit"
:style="`animation-delay: ${day_index / 15}s;`"
> >
<div <transition
v-if="ui_store.is_mobile_mode" appear
class="col column full-width" enter-active-class="animated fadeInDown"
leave-active-class="animated fadeOutUp"
> >
<q-card <q-btn
class="rounded-10" v-if="!$q.platform.is.mobile"
:class="(getDayApproval(day) || timesheet.is_approved) ? 'bg-accent' : 'bg-dark'" :disable="!timesheet.days.every(day => day.shifts.length < 1)"
:style="ui_store.is_mobile_mode ? ((getDayApproval(day) || timesheet.is_approved) ? 'border: 6px inset var(--q-accent)' : 'border: 1px solid var(--q-accent);') : ''" flat
dense
:label="$t('timesheet.apply_preset_week')"
class="text-uppercase text-weight-bold text-accent q-mx-lg q-py-none rounded-5"
:class="timesheet.days.every(day => day.shifts.length < 1) ? '' : 'invisible'"
@click="timesheet_api.applyPreset(timesheet.timesheet_id)"
> >
<q-card-section
class="text-weight-bolder text-uppercase text-h6 q-py-xs"
:class="(getDayApproval(day) || timesheet.is_approved) ? 'bg-accent text-white' : 'bg-primary text-white'"
style="line-height: 1em;"
>
<span> {{ $d(extractDate(day.date, 'YYYY-MM-DD'), {
weekday: 'long', day: 'numeric', month:
'long'
}) }}</span>
</q-card-section>
<q-card-section
v-if="day.shifts.filter(shift => shift.id !== 0).length > 0"
class="q-pa-none transparent"
>
<ShiftListDay
outlined
:animation-delay-multiplier="day_index"
:approved="(getDayApproval(day) || timesheet.is_approved)"
:day="day"
@delete-unsaved-shift="deleteUnsavedShift(timesheet_index, day_index)"
/>
</q-card-section>
<q-card-actions class="q-pa-none">
<q-btn
v-if="!(getDayApproval(day) || timesheet.is_approved)"
square
color="accent"
icon="more_time"
class="full-width"
style="border-radius: 0 0 5px 5px;"
@click="addNewShift(day.shifts, day.date, timesheet.timesheet_id)"
/>
</q-card-actions>
<q-badge
v-if="(getDayApproval(day) || timesheet.is_approved)"
floating
class="transparent q-pa-none rounded-50"
style="transform: translate(15px, -5px);"
>
<q-icon
name="verified"
size="5em"
color="white"
/>
</q-badge>
</q-card>
</div>
<div
v-else
class="col row full-width"
:class="(getDayApproval(day) || timesheet.is_approved) ? 'rounded-10 bg-accent' : ''"
>
<!-- List of shifts -->
<div
class="col row bg-dark"
:class="(getDayApproval(day) || timesheet.is_approved) ? 'bg-transparent' : ''"
style="border-radius: 10px 0 0 10px;"
>
<!-- Date block -->
<ShiftListDateWidget
:display-date="day.date"
:approved="(getDayApproval(day) || timesheet.is_approved)"
class="col-auto"
/>
<ShiftListDay
:day="day"
:approved="getDayApproval(day) || timesheet.is_approved"
class="col"
@delete-unsaved-shift="deleteUnsavedShift(timesheet_index, day_index)"
/>
</div>
<div class="col-auto self-stretch">
<q-icon <q-icon
v-if="(getDayApproval(day) || timesheet.is_approved)" name="las la-calendar-week"
name="verified"
color="white"
size="xl"
class="full-height"
/>
<q-btn
v-else
:dense="!ui_store.is_mobile_mode"
icon="more_time"
size="lg"
color="accent" color="accent"
text-color="white" size="md"
class="full-height"
:class="$q.screen.lt.md ? 'q-px-xs ' : ' '"
style="border-radius: 0 10px 10px 0;"
@click="addNewShift(day.shifts, day.date, timesheet.timesheet_id)"
/> />
</q-btn>
</transition>
<transition-group
appear
:enter-active-class="`animated ${animation_style}`"
>
<div
v-for="day, day_index in timesheet.days"
:key="day.date"
class="col-auto row rounded-10 q-ma-sm shadow-10"
:style="`animation-delay: ${day_index / 15}s;`"
>
<div
v-if="$q.platform.is.mobile && ($q.screen.width < $q.screen.height)"
class="col column full-width"
>
<q-card
class="rounded-10"
:class="(getDayApproval(day) || timesheet.is_approved) ? 'bg-accent' : 'bg-dark'"
:style="ui_store.is_mobile_mode ? ((getDayApproval(day) || timesheet.is_approved) ? 'border: 6px inset var(--q-accent)' : 'border: 1px solid var(--q-accent);') : ''"
>
<q-card-section
class="text-weight-bolder text-uppercase text-h6 q-py-xs"
:class="(getDayApproval(day) || timesheet.is_approved) ? 'bg-accent text-white' : 'bg-primary text-white'"
style="line-height: 1em;"
>
<span> {{ $d(extractDate(day.date, 'YYYY-MM-DD'), {
weekday: 'long', day: 'numeric', month:
'long'
}) }}</span>
</q-card-section>
<q-card-section
v-if="day.shifts.filter(shift => shift.id !== 0).length > 0"
class="q-pa-none transparent"
>
<ShiftListDay
outlined
:timesheet-id="timesheet.timesheet_id"
:week-day-index="day_index"
:animation-delay-multiplier="day_index"
:approved="(getDayApproval(day) || timesheet.is_approved)"
:day="day"
@delete-unsaved-shift="deleteUnsavedShift(timesheet_index, day_index)"
/>
</q-card-section>
<q-card-actions class="q-pa-none">
<q-btn
v-if="!(getDayApproval(day) || timesheet.is_approved)"
square
color="accent"
icon="more_time"
class="full-width"
style="border-radius: 0 0 5px 5px;"
@click="addNewShift(day.shifts, day.date, timesheet.timesheet_id)"
/>
</q-card-actions>
<q-badge
v-if="(getDayApproval(day) || timesheet.is_approved)"
floating
class="transparent q-pa-none rounded-50"
style="transform: translate(15px, -5px);"
>
<q-icon
name="verified"
size="5em"
color="white"
/>
</q-badge>
</q-card>
</div>
<div
v-else
class="col row full-width"
:class="(getDayApproval(day) || timesheet.is_approved) ? 'rounded-10 bg-accent' : ''"
>
<transition
appear
enter-active-class="animated fadeInDown"
leave-active-class="animated fadeOutUp"
>
</transition>
<!-- List of shifts -->
<div
class="col row bg-dark"
:class="(getDayApproval(day) || timesheet.is_approved) ? 'bg-transparent' : ''"
style="border-radius: 10px 0 0 10px;"
>
<!-- Date block -->
<ShiftListDateWidget
:display-date="day.date"
:approved="(getDayApproval(day) || timesheet.is_approved)"
class="col-auto"
/>
<ShiftListDay
:timesheet-id="timesheet.timesheet_id"
:week-day-index="day_index"
:day="day"
:approved="getDayApproval(day) || timesheet.is_approved"
class="col"
@delete-unsaved-shift="deleteUnsavedShift(timesheet_index, day_index)"
/>
</div>
<div class="col-auto self-stretch">
<q-icon
v-if="(getDayApproval(day) || timesheet.is_approved)"
name="verified"
color="white"
size="xl"
class="full-height"
/>
<q-btn
v-else
:dense="!ui_store.is_mobile_mode"
icon="more_time"
size="lg"
color="accent"
text-color="white"
class="full-height"
:class="$q.screen.lt.md ? 'q-px-xs ' : ' '"
style="border-radius: 0 10px 10px 0;"
@click="addNewShift(day.shifts, day.date, timesheet.timesheet_id)"
/>
</div>
</div>
</div> </div>
</div> </transition-group>
</div> </div>
</transition-group> </div>
</div>
<q-page-sticky
position="bottom-right"
:offset="[0, -35]"
class="z-top"
>
<transition
appear
enter-active-class="animated zoomIn"
leave-active-class="animated zoomOut"
>
<q-btn
v-if="scroll_y > 400"
fab
icon="las la-chevron-up"
color="white"
text-color="accent"
class="shadow-12"
@click="timesheet_page!.setScrollPosition('vertical', 0, 300)"
/>
</transition>
</q-page-sticky>
</q-scroll-area>
</div> </div>
</template> </template>

View File

@ -9,11 +9,12 @@
import TimesheetErrorWidget from 'src/modules/timesheets/components/timesheet-error-widget.vue'; import TimesheetErrorWidget from 'src/modules/timesheets/components/timesheet-error-widget.vue';
import LoadingOverlay from 'src/modules/shared/components/loading-overlay.vue'; import LoadingOverlay from 'src/modules/shared/components/loading-overlay.vue';
import { computed } from 'vue'; import { computed, onMounted } 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 { date } from 'quasar';
const expenses_store = useExpensesStore(); const expenses_store = useExpensesStore();
@ -22,93 +23,90 @@
const shift_api = useShiftApi(); const shift_api = useShiftApi();
const has_shift_errors = computed(() => timesheet_store.all_current_shifts.filter(shift => shift.has_error === true).length > 0); const has_shift_errors = computed(() => timesheet_store.all_current_shifts.filter(shift => shift.has_error === true).length > 0);
const is_timesheets_approved = computed(() => timesheet_store.timesheets.every(timesheet => timesheet.is_approved))
const { mode = 'normal' } = defineProps<{ const { mode = 'normal' } = defineProps<{
mode?: 'approval' | 'normal'; mode?: 'approval' | 'normal';
}>(); }>();
onMounted(async () => {
await timesheet_api.getTimesheetsByDate(date.formatDate(new Date(), 'YYYY-MM-DD'));
});
</script> </script>
<template> <template>
<div class="column flex-center full-width"> <div class="column items-center full-height">
<LoadingOverlay v-model="timesheet_store.is_loading" /> <LoadingOverlay v-model="timesheet_store.is_loading" />
<q-card <div
flat class="col-auto row items-center full-width"
class="transparent full-width" :class="$q.platform.is.mobile && ($q.screen.width < $q.screen.height) ? 'justify-between' : 'q-mt-md q-px-md'"
> >
<q-card-section <!-- navigation btn -->
:horizontal="$q.screen.gt.sm" <PayPeriodNavigator
class="q-px-md items-center q-mb-md" v-if="mode === 'normal'"
:class="$q.screen.lt.md ? 'column' : ''" class="col-auto"
> @date-selected="date_value => timesheet_api.getTimesheetsByDate(date_value)"
<!-- navigation btn --> @pressed-previous-button="timesheet_api.getTimesheetsByCurrentPayPeriod"
<PayPeriodNavigator @pressed-next-button="timesheet_api.getTimesheetsByCurrentPayPeriod"
v-if="mode === 'normal'" />
@date-selected="date_value => timesheet_api.getTimesheetsByDate(date_value)"
@pressed-previous-button="timesheet_api.getTimesheetsByCurrentPayPeriod"
@pressed-next-button="timesheet_api.getTimesheetsByCurrentPayPeriod"
/>
<!-- mobile expenses button --> <!-- mobile expenses button -->
<q-btn <q-btn
v-if="$q.screen.lt.md && mode === 'normal'" v-if="($q.platform.is.mobile && ($q.screen.width < $q.screen.height))"
push push
rounded rounded
color="accent" color="accent"
icon="receipt_long" icon="receipt_long"
:label="$t('timesheet.expense.open_btn')" :label="$t('timesheet_approvals.table.expenses')"
class="q-mt-sm" class="col-auto"
@click="expenses_store.open" @click="expenses_store.open"
/> />
<q-space /> <q-space v-if="!$q.platform.is.mobile" />
<!-- save timesheet changes button --> <!-- desktop save timesheet changes button -->
<q-btn <q-btn
v-if="mode === 'normal'" v-if="mode === 'normal' && !is_timesheets_approved && !$q.platform.is.mobile"
push push
rounded rounded
:disable="timesheet_store.is_loading || has_shift_errors" :disable="timesheet_store.is_loading || has_shift_errors"
:color="timesheet_store.is_loading || has_shift_errors ? 'grey-5' : 'accent'" :color="timesheet_store.is_loading || has_shift_errors ? 'grey-5' : 'accent'"
icon="upload" icon="upload"
:label="$t('shared.label.save')" :label="$t('shared.label.save')"
class="q-mr-md" :class="$q.platform.is.mobile && ($q.screen.width < $q.screen.height) ? 'full-width' : 'q-mr-md'"
@click="shift_api.saveShiftChanges" @click="shift_api.saveShiftChanges"
/> />
<!-- desktop expenses button --> <!-- desktop expenses button -->
<q-btn <q-btn
v-if="mode === 'normal'" v-if="mode === 'normal' && $q.screen.width > $q.screen.height"
push push
rounded rounded
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="expenses_store.open"
/> />
</div>
</q-card-section> <TimesheetErrorWidget class="col-auto"/>
<ShiftList />
<q-btn
v-if="mode === 'approval' || $q.platform.is.mobile && $q.screen.width < $q.screen.height"
push
rounded
:disable="timesheet_store.is_loading"
color="accent"
icon="upload"
:label="$t('shared.label.save')"
class="col-auto"
:class="$q.platform.is.mobile && $q.screen.width < $q.screen.height ? 'full-width q-mt-sm' : 'q-mr-md'"
@click="shift_api.saveShiftChanges"
/>
<q-card-section class="q-pa-none"> <ExpenseDialog :is-approved="is_timesheets_approved" />
<TimesheetErrorWidget />
</q-card-section>
<ShiftList :mode="mode" />
<q-card-actions align="right">
<q-btn
v-if="mode === 'approval'"
push
rounded
:disable="timesheet_store.is_loading"
color="accent"
icon="upload"
:label="$t('shared.label.save')"
class="q-mr-md"
@click="shift_api.saveShiftChanges"
/>
</q-card-actions>
</q-card>
<ExpenseDialog />
</div> </div>
</template> </template>

View File

@ -1,7 +1,8 @@
/* eslint-disable */ /* eslint-disable */
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 type { Expense } from "src/modules/timesheets/models/expense.models"; import { Expense } from "src/modules/timesheets/models/expense.models";
import { date } from "quasar";
export const useExpensesApi = () => { export const useExpensesApi = () => {
const expenses_store = useExpensesStore(); const expenses_store = useExpensesStore();
@ -10,6 +11,7 @@ export const useExpensesApi = () => {
const upsertExpense = async (expense: Expense): Promise<void> => { const upsertExpense = async (expense: Expense): Promise<void> => {
const success = await expenses_store.upsertExpense(expense); const success = await expenses_store.upsertExpense(expense);
if (success) { if (success) {
expenses_store.current_expense = new Expense(date.formatDate( new Date(), 'YYYY-MM-DD'));
timesheet_store.getTimesheetsByOptionalEmployeeEmail(); timesheet_store.getTimesheetsByOptionalEmployeeEmail();
} }
}; };

View File

@ -1,4 +1,5 @@
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 timesheet_store = useTimesheetStore();
@ -17,7 +18,7 @@ export const useTimesheetApi = () => {
const getTimesheetsByCurrentPayPeriod = async (employee_email?: string) => { const getTimesheetsByCurrentPayPeriod = async (employee_email?: string) => {
if (timesheet_store.pay_period === undefined) return false; if (timesheet_store.pay_period === undefined) return false;
timesheet_store.is_loading = true; timesheet_store.is_loading = true;
const success = await timesheet_store.getPayPeriodByDateOrYearAndNumber(); const success = await timesheet_store.getPayPeriodByDateOrYearAndNumber();
@ -29,8 +30,41 @@ export const useTimesheetApi = () => {
timesheet_store.is_loading = false; timesheet_store.is_loading = false;
}; };
const applyPreset = async (timesheet_id: number, week_day_index?: number, date?: string) => {
if (timesheet_store.timesheets.map(timesheet => timesheet.timesheet_id).includes(timesheet_id)) {
timesheet_store.is_loading = true;
try {
let response;
if (week_day_index && date)
response = await timesheetService.applyPresetToDay(timesheet_id, week_day_index, date);
else
response = await timesheetService.applyPresetToWeek(timesheet_id);
if (response.success)
await timesheet_store.getTimesheetsByOptionalEmployeeEmail();
} catch (error) {
console.error('Error applying weekly timesheet: ', error);
}
timesheet_store.is_loading = false;
}
}
const getTimesheetsBySwiping = async( direction: number ) => {
timesheet_store.is_loading = true;
timesheet_store.getNextOrPreviousPayPeriod(direction);
await timesheet_store.getPayPeriodByDateOrYearAndNumber();
await timesheet_store.getTimesheetsByOptionalEmployeeEmail();
timesheet_store.is_loading = false;
}
return { return {
getTimesheetsByDate, getTimesheetsByDate,
getTimesheetsByCurrentPayPeriod, getTimesheetsByCurrentPayPeriod,
getTimesheetsBySwiping,
applyPreset,
}; };
}; };

View File

@ -14,7 +14,6 @@ export const ShiftService = {
}, },
updateShifts: async (existing_shifts: Shift[]):Promise<BackendResponse<Shift>> => { updateShifts: async (existing_shifts: Shift[]):Promise<BackendResponse<Shift>> => {
console.log('sent shifts: ', existing_shifts)
const response = await api.patch(`/shift/update`, existing_shifts); const response = await api.patch(`/shift/update`, existing_shifts);
return response.data; return response.data;
} }

View File

@ -2,30 +2,41 @@ import { api } from "src/boot/axios";
import type { PayPeriod } from "src/modules/shared/models/pay-period.models"; import type { PayPeriod } from "src/modules/shared/models/pay-period.models";
import type { TimesheetResponse } from "src/modules/timesheets/models/timesheet.models"; import type { TimesheetResponse } from "src/modules/timesheets/models/timesheet.models";
import type { TimesheetOverview } from "src/modules/timesheet-approval/models/timesheet-overview.models"; import type { TimesheetOverview } from "src/modules/timesheet-approval/models/timesheet-overview.models";
import type { BackendResponse } from "src/modules/shared/models/backend-response.models";
export const timesheetService = { export const timesheetService = {
getPayPeriodByDate: async (date_string: string): Promise<PayPeriod> => { getPayPeriodByDate: async (date_string: string): Promise<PayPeriod> => {
const response = await api.get<{success: boolean, data: PayPeriod, error? : string}>(`pay-periods/date/${date_string}`); const response = await api.get<{ success: boolean, data: PayPeriod, error?: string }>(`pay-periods/date/${date_string}`);
return response.data.data; return response.data.data;
}, },
getPayPeriodByYearAndPeriodNumber: async (year: number, period_number: number): Promise<PayPeriod> => { getPayPeriodByYearAndPeriodNumber: async (year: number, period_number: number): Promise<PayPeriod> => {
const response = await api.get<{success: boolean, data: PayPeriod, error? : string}>(`pay-periods/${year}/${period_number}`); const response = await api.get<{ success: boolean, data: PayPeriod, error?: string }>(`pay-periods/${year}/${period_number}`);
return response.data.data; return response.data.data;
}, },
getTimesheetOverviewsByPayPeriodAndSupervisorEmail: async (year: number, period_number: number, supervisor_email: string): Promise<TimesheetOverview[]> => { getTimesheetOverviewsByPayPeriodAndSupervisorEmail: async (year: number, period_number: number, supervisor_email: string): Promise<TimesheetOverview[]> => {
const response = await api.get<{success: boolean, data: TimesheetOverview[], error? : string}>(`pay-periods/${year}/${period_number}/${supervisor_email}`); const response = await api.get<{ success: boolean, data: TimesheetOverview[], error?: string }>(`pay-periods/${year}/${period_number}/${supervisor_email}`);
return response.data.data; return response.data.data;
}, },
getTimesheetsByPayPeriodAndOptionalEmail: async (year: number, period_number: number, employee_email?: string): Promise<TimesheetResponse> => { getTimesheetsByPayPeriodAndOptionalEmail: async (year: number, period_number: number, employee_email?: string): Promise<BackendResponse<TimesheetResponse>> => {
if (employee_email !== undefined) { if (employee_email !== undefined) {
const response = await api.get<{success: boolean, data: TimesheetResponse, error? : string}>(`timesheets/${year}/${period_number}?employee_email=${employee_email}`); const response = await api.get<BackendResponse<TimesheetResponse>>(`timesheets/${year}/${period_number}?employee_email=${employee_email}`);
return response.data.data; return response.data;
} else { } else {
const response = await api.get<{success: boolean, data: TimesheetResponse, error? : string}>(`timesheets/${year}/${period_number}`); const response = await api.get<BackendResponse<TimesheetResponse>>(`timesheets/${year}/${period_number}`);
return response.data.data; return response.data;
} }
}, },
applyPresetToWeek: async (timesheet_id: number): Promise<BackendResponse<boolean>> => {
const response = await api.post<BackendResponse<boolean>>(`schedule-presets/apply-preset`, { timesheet_id });
return response.data;
},
applyPresetToDay: async (timesheet_id: number, week_day_index: number, date: string): Promise<BackendResponse<boolean>> => {
const response = await api.post<BackendResponse<boolean>>('schedule-presets/apply-day-preset', { timesheet_id, week_day_index, date });
return response.data;
}
}; };

View File

@ -3,7 +3,7 @@ import type { ExpenseType } from "src/modules/timesheets/models/expense.models";
export const getExpenseIcon = (type: ExpenseType) => { export const getExpenseIcon = (type: ExpenseType) => {
switch (type) { switch (type) {
case 'MILEAGE': return 'time_to_leave'; case 'MILEAGE': return 'time_to_leave';
case 'EXPENSES': return 'receipt_long'; case 'EXPENSES': return 'las la-coins';
case 'PER_DIEM': return 'hotel'; case 'PER_DIEM': return 'hotel';
case 'ON_CALL': return 'phone_android'; case 'ON_CALL': return 'phone_android';
default: return 'help_outline'; default: return 'help_outline';
@ -15,7 +15,7 @@ export const useExpenseRules = (t: (_key: string) => string) => {
const typeRequired = (val: unknown) => (!!val) || t('timesheet.expense.errors.type_required'); const typeRequired = (val: unknown) => (!!val) || t('timesheet.expense.errors.type_required');
const amountRequired = (val: unknown) => (isPresent(val)) || t('timesheet.expense.errors.amount_required_for_type'); const amountRequired = (val: unknown) => (isPresent(val)) || t('timesheet.expense.errors.amount_required_for_type');
const mileageRequired = (val: unknown) => (isPresent(val)) || t('timesheet.expense.errors.mileage_required_for_type'); const mileageRequired = (val: unknown) => (isPresent(val)) || t('timesheet.expense.errors.mileage_required_for_type');
const commentRequired = (val: unknown) => (typeof val === 'string' ? val.trim().length > 0 : false) || t('timesheet.expense.errors.comment_required'); const commentRequired = (val: unknown) => (typeof val === 'string' ? val.trim().length > 0 : false) || t('timesheet.expense.hints.comment_required');
return { return {
typeRequired, typeRequired,
@ -23,21 +23,4 @@ export const useExpenseRules = (t: (_key: string) => string) => {
mileageRequired, mileageRequired,
commentRequired, commentRequired,
}; };
};
export const convertToMonetaryAmount = (amount: number | string): number => {
if (typeof amount === 'number') return Number(amount.toFixed(2));
if (typeof amount === 'string') {
try {
const single_decimal_amount = amount.replace(/\.(?=.*\.)/g, '');
const numbers_only_decimal = single_decimal_amount.replace(/[^0-9.]/g, '');
return Number(numbers_only_decimal);
} catch(error) {
console.error(error);
}
}
return 0;
}; };

View File

@ -1,6 +1,6 @@
import { date, patterns, type ValidationRule } from "quasar"; import { date, patterns, type ValidationRule } 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 } from "src/modules/timesheets/models/shift.models"; import type { Shift, ShiftOption } 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;
@ -9,8 +9,6 @@ export const isShiftOverlap = (shifts: Shift[] | SchedulePresetShift[]): boolean
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(),
})); }));
console.log('parsed_shifts: ', parsed_shifts);
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++) {
@ -18,8 +16,6 @@ export const isShiftOverlap = (shifts: Shift[] | SchedulePresetShift[]): boolean
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;
console.log('times(a start, b start, a end, b end): ', parsed_shift_a.start, parsed_shift_b.start, parsed_shift_a.end, parsed_shift_b.end);
console.log('result: ', 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)) { 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
@ -30,10 +26,21 @@ export const isShiftOverlap = (shifts: Shift[] | SchedulePresetShift[]): boolean
return false; return false;
}; };
export const useShiftRules = (time_required_error: string) => { export const useShiftRules = (time_required_error: string, overlap_error_string: string, day_shifts: Shift[]) => {
const isTimeRequired: ValidationRule<string> = (time_string: string) => (!!time_string && patterns.testPattern.time(time_string)) || time_required_error; const isTimeRequiredRule: ValidationRule<string> = (time_string: string) => (!!time_string && patterns.testPattern.time(time_string)) || time_required_error;
const isShiftOverlapRule: ValidationRule<string> = (_time_string: string) => !isShiftOverlap(day_shifts) || overlap_error_string;
return { return {
isTimeRequired, isTimeRequiredRule,
isShiftOverlapRule
}; };
}; };
export const SHIFT_OPTIONS: ShiftOption[] = [
{ 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' },
{ 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' },
];

View File

@ -2,21 +2,14 @@
setup setup
lang="ts" lang="ts"
> >
import { date } from 'quasar';
import { onMounted } from 'vue';
import { useAuthStore } from 'src/stores/auth-store';
import { useTimesheetApi } from 'src/modules/timesheets/composables/use-timesheet-api';
import { useTimesheetStore } from 'src/stores/timesheet-store';
import PageHeaderTemplate from 'src/modules/shared/components/page-header-template.vue'; import PageHeaderTemplate from 'src/modules/shared/components/page-header-template.vue';
import TimesheetWrapper from 'src/modules/timesheets/components/timesheet-wrapper.vue'; import TimesheetWrapper from 'src/modules/timesheets/components/timesheet-wrapper.vue';
import { useAuthStore } from 'src/stores/auth-store';
import { useTimesheetStore } from 'src/stores/timesheet-store';
const { user } = useAuthStore(); const { user } = useAuthStore();
const timesheet_store = useTimesheetStore(); const timesheet_store = useTimesheetStore();
const timesheet_api = useTimesheetApi();
onMounted(async () => {
await timesheet_api.getTimesheetsByDate(date.formatDate(new Date(), 'YYYY-MM-DD'));
});
</script> </script>
@ -33,11 +26,10 @@
/> />
<div <div
class="col" class="col column fit"
:style="$q.screen.gt.sm ? 'width: 90vw' : ''" :style="$q.platform.is.mobile && ($q.screen.width < $q.screen.height) ? '' : 'width: 90vw'"
> >
<TimesheetWrapper :employee-email="user?.email ?? ''" /> <TimesheetWrapper :employee-email="user?.email ?? ''" class="col"/>
</div> </div>
</q-page> </q-page>
</template> </template>

View File

@ -33,7 +33,7 @@ export default defineRouter(function (/* { store, ssrContext } */) {
const result = await authStore.getProfile() ?? { status: 400, message: 'unknown error occured' }; const result = await authStore.getProfile() ?? { status: 400, message: 'unknown error occured' };
if ((destinationPage.meta.requiresAuth && !authStore.isAuthorizedUser) || (result.status >= 400 && destinationPage.name !== RouteNames.LOGIN)) { if ((destinationPage.meta.requiresAuth && !authStore.isAuthorizedUser) || (result.status >= 400 && destinationPage.name !== RouteNames.LOGIN)) {
console.log('no user account found'); console.error('no user account found');
return { name: 'login' }; return { name: 'login' };
} }
}) })

View File

@ -1,31 +1,32 @@
import { ref } from "vue"; import { date } from "quasar";
import { computed, ref } from "vue";
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { useTimesheetStore } from "src/stores/timesheet-store"; import { useTimesheetStore } from "src/stores/timesheet-store";
import { Expense } from "src/modules/timesheets/models/expense.models"; import { Expense } from "src/modules/timesheets/models/expense.models";
import { ExpenseService } from "src/modules/timesheets/services/expense-service"; import { ExpenseService } from "src/modules/timesheets/services/expense-service";
import { date } from "quasar";
export const useExpensesStore = defineStore('expenses', () => { export const useExpensesStore = defineStore('expenses', () => {
const timesheet_store = useTimesheetStore(); const timesheet_store = useTimesheetStore();
const is_open = ref(false); const is_open = ref(false);
const is_loading = ref(false); const is_loading = ref(false);
const is_hiding_create_form = ref(false); const is_showing_create_form = ref(true);
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 is_save_disabled = computed(() => JSON.stringify(current_expense.value) === JSON.stringify(initial_expense.value))
const open = (): void => { const open = (): void => {
is_open.value = true; is_open.value = true;
if (timesheet_store.pay_period !== undefined) { if (timesheet_store.pay_period !== undefined) {
current_expense.value = new Expense(timesheet_store.pay_period.period_start); current_expense.value = new Expense(date.formatDate(new Date(), 'YYYY-MM-DD'));
initial_expense.value = new Expense(timesheet_store.pay_period.period_start); initial_expense.value = new Expense(date.formatDate(new Date(), 'YYYY-MM-DD'));
} }
mode.value = 'create'; mode.value = 'create';
} }
const close = () => { const close = () => {
is_open.value = false; is_open.value = false;
is_hiding_create_form.value = false; is_showing_create_form.value = true;
}; };
const upsertExpense = async (expense: Expense): Promise<boolean> => { const upsertExpense = async (expense: Expense): Promise<boolean> => {
@ -34,8 +35,8 @@ export const useExpensesStore = defineStore('expenses', () => {
const data = await ExpenseService.createExpense(expense); const data = await ExpenseService.createExpense(expense);
return data.success; return data.success;
} }
const data = await ExpenseService.updateExpense(expense); const data = await ExpenseService.updateExpense(expense);
return data.success; return data.success;
} catch (err) { } catch (err) {
// setErrorFrom(err); // setErrorFrom(err);
console.error(err); console.error(err);
@ -51,10 +52,11 @@ export const useExpensesStore = defineStore('expenses', () => {
return { return {
is_open, is_open,
is_loading, is_loading,
is_hiding_create_form, is_showing_create_form,
mode, mode,
current_expense, current_expense,
initial_expense, initial_expense,
is_save_disabled,
open, open,
upsertExpense, upsertExpense,
deleteExpenseById, deleteExpenseById,

View File

@ -27,6 +27,22 @@ export const useTimesheetStore = defineStore('timesheet', () => {
const is_approval_grid_mode = ref<boolean>(true); const is_approval_grid_mode = ref<boolean>(true);
const pay_period_report = ref(); const pay_period_report = ref();
const getNextOrPreviousPayPeriod = (direction: number) => {
if (!pay_period.value) return;
pay_period.value.pay_period_no += direction;
if (pay_period.value.pay_period_no > 26) {
pay_period.value.pay_period_no = 1;
pay_period.value.pay_year += direction;
}
if (pay_period.value.pay_period_no < 1) {
pay_period.value.pay_period_no = 26;
pay_period.value.pay_year += direction;
}
};
const getPayPeriodByDateOrYearAndNumber = async (date?: string): Promise<boolean> => { const getPayPeriodByDateOrYearAndNumber = async (date?: string): Promise<boolean> => {
try { try {
if (date !== undefined) { if (date !== undefined) {
@ -72,7 +88,6 @@ export const useTimesheetStore = defineStore('timesheet', () => {
const getTimesheetsByOptionalEmployeeEmail = async (employee_email?: string) => { const getTimesheetsByOptionalEmployeeEmail = async (employee_email?: string) => {
if (pay_period.value === undefined) return; if (pay_period.value === undefined) return;
console.log('pay period: ', pay_period.value);
is_loading.value = true; is_loading.value = true;
let response; let response;
@ -82,10 +97,16 @@ export const useTimesheetStore = defineStore('timesheet', () => {
} else { } else {
response = await timesheetService.getTimesheetsByPayPeriodAndOptionalEmail(pay_period.value.pay_year, pay_period.value.pay_period_no); response = await timesheetService.getTimesheetsByPayPeriodAndOptionalEmail(pay_period.value.pay_year, pay_period.value.pay_period_no);
} }
selected_employee_name.value = response.employee_fullname; if (response.success && response.data) {
timesheets.value = response.timesheets; selected_employee_name.value = response.data.employee_fullname;
initial_timesheets.value = unwrapAndClone(timesheets.value); timesheets.value = response.data.timesheets;
initial_timesheets.value = unwrapAndClone(timesheets.value);
} else {
selected_employee_name.value = '';
timesheets.value = [];
initial_timesheets.value = [];
}
is_loading.value = false; is_loading.value = false;
} catch (error) { } catch (error) {
console.error('There was an error retrieving timesheet details for this employee: ', error); console.error('There was an error retrieving timesheet details for this employee: ', error);
@ -135,6 +156,7 @@ export const useTimesheetStore = defineStore('timesheet', () => {
timesheets, timesheets,
all_current_shifts, all_current_shifts,
initial_timesheets, initial_timesheets,
getNextOrPreviousPayPeriod,
getPayPeriodByDateOrYearAndNumber, getPayPeriodByDateOrYearAndNumber,
getTimesheetOverviews, getTimesheetOverviews,
getTimesheetsByOptionalEmployeeEmail, getTimesheetsByOptionalEmployeeEmail,

View File

@ -68,7 +68,6 @@ export const useUiStore = defineStore('ui', () => {
Dark.set(user_preferences.value.is_dark_mode ?? "auto"); Dark.set(user_preferences.value.is_dark_mode ?? "auto");
locale.value = user_preferences.value.display_language; locale.value = user_preferences.value.display_language;
} }
console.log('quasar dark mode: ', q.dark.mode, 'preferences: ', user_preferences.value.is_dark_mode);
} }
return { return {

View File

@ -10,7 +10,6 @@ export const getCurrentPayPeriod = (today = new Date()): number => {
const periods_since_anchor = Math.floor(days_since_anchor / period_length); const periods_since_anchor = Math.floor(days_since_anchor / period_length);
const current_period = (periods_since_anchor % periods_per_year) + 1; const current_period = (periods_since_anchor % periods_per_year) + 1;
console.log(current_period);
return current_period; return current_period;
} }

View File

@ -1,41 +0,0 @@
import { unwrapAndClone } from "src/utils/unwrap-and-clone";
/**
* Internal recursive function comparing two plain values.
*/
const _deepEqualRecursive = (a: unknown, b: unknown): boolean => {
if (a === b) return true;
if (a == null || b == null || typeof a !== "object" || typeof b !== "object") {
return false;
}
// Handle arrays
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return false;
return a.every((val, i) => _deepEqualRecursive(val, b[i]));
} else if (Array.isArray(a) || Array.isArray(b)) {
return false; // one is array, other is not
}
const aKeys = Object.keys(a as Record<string, unknown>);
const bKeys = Object.keys(b as Record<string, unknown>);
if (aKeys.length !== bKeys.length) return false;
return aKeys.every((key) =>
_deepEqualRecursive(
(a as Record<string, unknown>)[key],
(b as Record<string, unknown>)[key]
)
);
};
/**
* Deep equality check that normalizes reactive objects first.
*/
export const deepEqual = (given: unknown, expected: unknown): boolean => {
const a = unwrapAndClone(given as object);
const b = unwrapAndClone(expected as object);
return _deepEqualRecursive(a, b);
};