refactor(timesheet): rework appearance and functionality

removed modal for shift creation/update to better match current timesheet app and avoid adding superfluous user actions. Tweaked appearance of timesheet and overall theme to remove overcrowding of colors/elements
This commit is contained in:
Nicolas Drolet 2025-11-07 17:02:54 -05:00
parent f0ef88a16c
commit ac6744ff18
32 changed files with 994 additions and 569 deletions

View File

@ -25,7 +25,7 @@
} }
body.body--dark { body.body--dark {
--q-secondary: #2b2f34; --q-secondary: #151520;
color: $grey-2; color: $grey-2;
} }
@ -42,3 +42,15 @@ body.body--dark {
background-color: #FFFA !important; background-color: #FFFA !important;
backdrop-filter: blur(5px); backdrop-filter: blur(5px);
} }
.q-btn--push::before {
border-bottom: 4px solid rgba(0,0,0, 0.25);
}
.q-btn--push:active {
transform: translateY(3px);
}
.q-btn--push:active::before {
border-bottom-width: 1px;
}

View File

@ -12,9 +12,9 @@
// to match your app's branding. // to match your app's branding.
// Tip: Use the "Theme Builder" on Quasar's documentation website. // Tip: Use the "Theme Builder" on Quasar's documentation website.
$primary : #019547; $primary : #30303A;
$secondary : #DAE0E7; $secondary : #DAE0E7;
$accent : #83f29f7d; $accent : #0c9a3b;
$dark-shadow-color : #00220f; $dark-shadow-color : #00220f;
@ -30,9 +30,10 @@ $input-autofill-color : #AAD5C4;
$field-dense-label-top : 5px !default; $field-dense-label-top : 5px !default;
$field-dense-label-font-size : 16px !default; $field-dense-label-font-size : 16px !default;
$button-shadow : 0 0 0 transparent;
$dark : #42444b; $dark : #40404C;
$dark-page : #343434; $dark-page : #343444;
$positive : #21ba45; $positive : #21ba45;
$negative : #e6364b; $negative : #e6364b;

View File

@ -151,12 +151,12 @@ export default {
REMOTE: "Remote work", REMOTE: "Remote work",
}, },
errors: { errors: {
not_found:"Shift not found", not_found: "Shift not found",
overlap:"An overlaps occured between 2 or more shifts", SHIFT_OVERLAP: "An overlaps occured between 2 or more shifts",
invalid:"Invalid shift`s entry", invalid: "Invalid shift`s entry",
unknown:"Unknown error", unknown: "Unknown error",
comment_required:"A comment is required", comment_required: "A comment is required",
comment_too_long:"Your comment is too long", comment_too_long: "Your comment is too long",
}, },
fields: { fields: {
start:"Start (HH:mm)", start:"Start (HH:mm)",
@ -197,7 +197,7 @@ export default {
PER_DIEM:"Per Diem", PER_DIEM:"Per Diem",
EXPENSES:"expense", EXPENSES:"expense",
MILEAGE:"mileage", MILEAGE:"mileage",
PRIME_GARDE:"on-call allowance", ON_CALL:"on-call allowance",
}, },
}, },
}, },

View File

@ -151,12 +151,12 @@ export default {
REMOTE: "Télétravail", REMOTE: "Télétravail",
}, },
errors: { errors: {
not_found:"Aucun quart trouvé", not_found: "Aucun quart trouvé",
overlap:"Il y a un chevauchement entre deux ou plusieurs quarts", SHIFT_OVERLAP: "Il y a un chevauchement entre deux ou plusieurs quarts",
invalid:"Entrée du quart invalide", invalid: "Entrée du quart invalide",
unknown:"Erreur inconnue", unknown: "Erreur inconnue",
comment_required:"un commentaire est requis", comment_required: "un commentaire est requis",
comment_too_long:"votre commentaire est trop long", comment_too_long: "votre commentaire est trop long",
}, },
fields: { fields: {
start:"Début (HH:mm)", start:"Début (HH:mm)",
@ -197,7 +197,7 @@ export default {
PER_DIEM:"Per diem", PER_DIEM:"Per diem",
EXPENSES:"dépense", EXPENSES:"dépense",
MILEAGE:"kilométrage", MILEAGE:"kilométrage",
PRIME_GARDE:"Prime de garde", ON_CALL:"Prime de garde",
}, },
}, },
}, },

View File

@ -1,4 +1,7 @@
<script lang="ts" setup> <script
lang="ts"
setup
>
import { useUiStore } from 'src/stores/ui-store'; import { useUiStore } from 'src/stores/ui-store';
import HeaderBarNotification from './main-layout-header-bar-notification.vue'; import HeaderBarNotification from './main-layout-header-bar-notification.vue';
@ -7,10 +10,22 @@
<template> <template>
<q-header elevated> <q-header elevated>
<q-toolbar> <q-toolbar class="q-px-sm">
<q-toolbar-title> <q-toolbar-title>
<q-btn flat dense color="white" icon="menu" @click="uiStore.toggleRightDrawer"> <q-btn
<q-img src="src/assets/logo-targo-white.svg" fit="contain" width="150px" height="30px"/> flat
dense
color="white"
@click="uiStore.toggleRightDrawer"
class="q-px-none"
>
<q-icon name="menu" size="lg" class="q-mr-lg"/>
<q-img
src="src/assets/logo-targo-white.svg"
fit="contain"
width="150px"
height="30px"
/>
</q-btn> </q-btn>
</q-toolbar-title> </q-toolbar-title>
<q-item class="q-pa-none"> <q-item class="q-pa-none">
@ -19,4 +34,3 @@
</q-toolbar> </q-toolbar>
</q-header> </q-header>
</template> </template>

View File

@ -32,14 +32,14 @@
<template> <template>
<q-drawer <q-drawer
v-model="ui_store.is_left_drawer_open" v-model="ui_store.is_left_drawer_open"
persistent :persistent="!ui_store.is_mobile_mode"
mini-to-overlay mini-to-overlay
elevated elevated
side="left" side="left"
:mini="is_mini" :mini="is_mini"
@mouseenter="is_mini = false" @mouseenter="is_mini = false"
@mouseleave="is_mini = true" @mouseleave="is_mini = true"
class="bg-dark" class="bg-dark z-max"
> >
<q-scroll-area class="fit"> <q-scroll-area class="fit">
<q-list> <q-list>
@ -53,7 +53,8 @@
<q-item-section avatar> <q-item-section avatar>
<q-icon <q-icon
name="home" name="home"
color="primary" color="accent"
size="lg"
/> />
</q-item-section> </q-item-section>
<q-item-section> <q-item-section>
@ -72,7 +73,8 @@
<q-item-section avatar> <q-item-section avatar>
<q-icon <q-icon
name="event_available" name="event_available"
color="primary" color="accent"
size="lg"
/> />
</q-item-section> </q-item-section>
<q-item-section> <q-item-section>
@ -92,7 +94,8 @@
<q-item-section avatar> <q-item-section avatar>
<q-icon <q-icon
name="view_list" name="view_list"
color="primary" color="accent"
size="lg"
/> />
</q-item-section> </q-item-section>
<q-item-section> <q-item-section>
@ -112,7 +115,8 @@
<q-item-section avatar> <q-item-section avatar>
<q-icon <q-icon
name="punch_clock" name="punch_clock"
color="primary" color="accent"
size="lg"
/> />
</q-item-section> </q-item-section>
<q-item-section> <q-item-section>
@ -131,7 +135,8 @@
<q-item-section avatar> <q-item-section avatar>
<q-icon <q-icon
name="account_box" name="account_box"
color="primary" color="accent"
size="lg"
/> />
</q-item-section> </q-item-section>
<q-item-section> <q-item-section>
@ -148,7 +153,8 @@
<q-item-section avatar> <q-item-section avatar>
<q-icon <q-icon
name="contact_support" name="contact_support"
color="primary" color="accent"
size="lg"
/> />
</q-item-section> </q-item-section>
<q-item-section> <q-item-section>
@ -167,7 +173,8 @@
<q-item-section avatar> <q-item-section avatar>
<q-icon <q-icon
name="exit_to_app" name="exit_to_app"
color="primary" color="accent"
size="lg"
/> />
</q-item-section> </q-item-section>
<q-item-section> <q-item-section>

View File

@ -7,6 +7,9 @@
import MenuPanelPreferences from 'src/modules/profile/components/shared/menu-panel-preferences.vue'; import MenuPanelPreferences from 'src/modules/profile/components/shared/menu-panel-preferences.vue';
import MenuTemplate from 'src/modules/profile/components/shared/menu-template.vue'; import MenuTemplate from 'src/modules/profile/components/shared/menu-template.vue';
import { default_employee_profile, type EmployeeProfile } from 'src/modules/employee-list/models/employee-profile.models'; import { default_employee_profile, type EmployeeProfile } from 'src/modules/employee-list/models/employee-profile.models';
import { useAuthStore } from 'src/stores/auth-store';
const auth_store = useAuthStore();
const PanelNames = { const PanelNames = {
PERSONAL_INFO: 'personal_info', PERSONAL_INFO: 'personal_info',
@ -24,8 +27,8 @@
class="rounded-5 bg-transparent q-pa-none" class="rounded-5 bg-transparent q-pa-none"
> >
<MenuTemplate <MenuTemplate
:first-name="employee_profile.first_name" :first-name="employee_profile.first_name === '' ? auth_store.user?.first_name ?? '' : employee_profile.first_name"
:last-name="employee_profile.last_name" :last-name="employee_profile.last_name === '' ? auth_store.user?.last_name ?? '' : employee_profile.last_name"
:initial-menu="PanelNames.PERSONAL_INFO" :initial-menu="PanelNames.PERSONAL_INFO"
> >
<template #tabs> <template #tabs>

View File

@ -1,5 +1,8 @@
<script setup lang="ts"> <script
const { userFirstName = '', userLastName = '' } = defineProps<{ setup
lang="ts"
>
defineProps<{
userFirstName: string; userFirstName: string;
userLastName: string; userLastName: string;
}>(); }>();
@ -13,6 +16,9 @@
class="rounded-5 q-mb-md shadow-2 col-auto" class="rounded-5 q-mb-md shadow-2 col-auto"
fit="cover" fit="cover"
> >
<div class="absolute-bottom text-h5 text-uppercase text-weight-bolder" style="line-height: 0.8em;">{{ userFirstName }} {{ userLastName }}</div> <div
class="absolute-bottom text-h5 text-uppercase text-weight-bolder"
style="line-height: 0.8em;"
>{{ userFirstName }} {{ userLastName }}</div>
</q-img> </q-img>
</template> </template>

View File

@ -22,7 +22,7 @@
autogrow autogrow
filled filled
debounce="500" debounce="500"
label-color="primary" label-color="accent"
class="q-ma-xs text-uppercase" class="q-ma-xs text-uppercase"
input-class="text-weight-medium text-h6" input-class="text-weight-medium text-h6"
:hide-hint="hint === ''" :hide-hint="hint === ''"

View File

@ -17,7 +17,7 @@
dense dense
:stack-label="!isEditing" :stack-label="!isEditing"
filled filled
label-color="primary" label-color="accent"
class="q-ma-xs text-h6 text-uppercase" class="q-ma-xs text-h6 text-uppercase"
popup-content-class="text-weight-medium text-h6" popup-content-class="text-weight-medium text-h6"
input-class="text-weight-medium" input-class="text-weight-medium"

View File

@ -2,7 +2,7 @@
import { ref } from 'vue'; import { ref } from 'vue';
import MenuHeader from 'src/modules/profile/components/shared/menu-header.vue'; import MenuHeader from 'src/modules/profile/components/shared/menu-header.vue';
const { firstName, lastName, initialMenu } = defineProps<{ const { initialMenu } = defineProps<{
firstName: string; firstName: string;
lastName: string; lastName: string;
initialMenu: string; initialMenu: string;
@ -33,8 +33,8 @@
v-model="current_menu" v-model="current_menu"
:vertical="$q.screen.gt.sm" :vertical="$q.screen.gt.sm"
dense dense
active-color="primary" active-color="accent"
indicator-color="primary" indicator-color="accent"
> >
<slot name="tabs"></slot> <slot name="tabs"></slot>
</q-tabs> </q-tabs>

View File

@ -21,13 +21,13 @@
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"
> >
<div class="text-primary 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) }}
</div> </div>
<div class="text-body2 q-mx-md text-weight-medium"> <div class="text-body2 q-mx-md text-weight-medium">
{{ $t('shared.misc.to') }} {{ $t('shared.misc.to') }}
</div> </div>
<div class="text-primary text-weight-bold text-h6"> <div class="text-accent text-weight-bold text-h6">
{{ $d(date.extractDate(endDate, 'YYYY-MM-DD'), date_format_options) }} {{ $d(date.extractDate(endDate, 'YYYY-MM-DD'), date_format_options) }}
</div> </div>
</div> </div>

View File

@ -63,7 +63,7 @@
<q-btn <q-btn
push rounded push rounded
icon="keyboard_arrow_left" icon="keyboard_arrow_left"
color="primary" color="accent"
@click="getPreviousPayPeriod" @click="getPreviousPayPeriod"
:disable="is_previous_pay_period_limit || timesheet_store.is_loading || is_disabled" :disable="is_previous_pay_period_limit || timesheet_store.is_loading || is_disabled"
class="q-mr-sm q-px-sm" class="q-mr-sm q-px-sm"
@ -81,7 +81,7 @@
<q-btn <q-btn
push rounded push rounded
icon="calendar_month" icon="calendar_month"
color="primary" color="accent"
@click="is_showing_calendar_picker = true" @click="is_showing_calendar_picker = true"
:disable="timesheet_store.is_loading || is_disabled" :disable="timesheet_store.is_loading || is_disabled"
class="q-px-xl" class="q-px-xl"
@ -99,7 +99,7 @@
<q-btn <q-btn
push rounded push rounded
icon="keyboard_arrow_right" icon="keyboard_arrow_right"
color="primary" color="accent"
@click="getNextPayPeriod" @click="getNextPayPeriod"
:disable="timesheet_store.is_loading || is_disabled" :disable="timesheet_store.is_loading || is_disabled"
class="q-ml-sm q-px-sm" class="q-ml-sm q-px-sm"

View File

@ -10,6 +10,7 @@
import { useExpenseRules } from 'src/modules/timesheets/utils/expense.util'; import { useExpenseRules } from 'src/modules/timesheets/utils/expense.util';
import { useExpensesApi } from 'src/modules/timesheets/composables/use-expense-api'; import { useExpensesApi } from 'src/modules/timesheets/composables/use-expense-api';
import { useTimesheetStore } from 'src/stores/timesheet-store'; import { useTimesheetStore } from 'src/stores/timesheet-store';
import { date } from 'quasar';
const { t } = useI18n(); const { t } = useI18n();
@ -24,29 +25,36 @@
const rules = useExpenseRules(t); const rules = useExpenseRules(t);
const background_color = computed(() => expenses_store.mode === 'update' ? 'accent' : ''); const background_color = computed(() => expenses_store.mode === 'update' ? 'accent' : '');
const openDatePicker = () => {
is_navigator_open.value = true;
if (expenses_store.current_expense.date === '') {
expenses_store.current_expense.date = date.formatDate(new Date(), 'YYYY-MM-DD');
}
};
const cancelUpdateMode = () => { const cancelUpdateMode = () => {
expenses_store.current_expense = new Expense; expenses_store.current_expense = new Expense;
expenses_store.initial_expense = new Expense; expenses_store.initial_expense = new Expense;
} expenses_store.mode = 'create';
};
const requestExpenseCreationOrUpdate = async () => { const requestExpenseCreationOrUpdate = async () => {
if (expenses_store.mode === 'create') await expenses_api.createExpenseByEmployeeEmail(employee_email ?? '', expenses_store.current_expense?.date ?? ''); if (expenses_store.mode === 'create') await expenses_api.createExpenseByEmployeeEmail(employee_email ?? '', expenses_store.current_expense?.date ?? '');
else await expenses_api.updateExpenseByEmployeeEmail(employee_email ?? '', expenses_store.current_expense?.date ?? ''); else await expenses_api.updateExpenseByEmployeeEmail(employee_email ?? '', expenses_store.current_expense?.date ?? '');
} };
</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.mode" :key="expenses_store.current_expense.id"
flat flat
@submit.prevent="requestExpenseCreationOrUpdate" @submit.prevent="requestExpenseCreationOrUpdate"
> >
<div class="text-subtitle2 q-py-sm"> <div class="text-uppercase text-weight-medium q-pt-sm q-px-lg">
{{ $t('timesheet.expense.add_expense') }} {{ $t('timesheet.expense.add_expense') }}
</div> </div>
<div class="row justify-between rounded-5"> <div class="row justify-between items-start rounded-5 q-px-lg q-pb-sm">
<!-- date selection input --> <!-- date selection input -->
<q-input <q-input
v-model="expenses_store.current_expense.date" v-model="expenses_store.current_expense.date"
@ -55,23 +63,29 @@
readonly readonly
stack-label stack-label
class="col q-px-xs" class="col q-px-xs"
:bg-color="background_color"
color="primary" color="primary"
:label="$t('timesheet.expense.date')" :label="$t('timesheet.expense.date')"
> >
<template #before> <template #prepend>
<q-btn <q-btn
push push
dense dense
icon="event" icon="event"
color="primary" color="accent"
@click="is_navigator_open = true" class="q-mr-sm"
@click="openDatePicker"
/> />
<q-dialog v-model="is_navigator_open">
<q-dialog
v-model="is_navigator_open"
transition-show="jump-right"
transition-hide="jump-right"
>
<q-date <q-date
v-model="expenses_store.current_expense.date" v-model="expenses_store.current_expense.date"
@update:model-value="is_navigator_open = false"
mask="YYYY-MM-DD" mask="YYYY-MM-DD"
event-color="accent"
@update:model-value="is_navigator_open = false"
/> />
</q-dialog> </q-dialog>
</template> </template>
@ -81,20 +95,25 @@
<q-select <q-select
v-model="expenses_store.current_expense.type" v-model="expenses_store.current_expense.type"
:options="EXPENSE_TYPE" :options="EXPENSE_TYPE"
filled standout="bg-blue-grey-9"
dense dense
class="col q-px-xs"
:bg-color="background_color"
color="primary"
emit-value emit-value
map-options map-options
hide-dropdown-icon
class="col q-px-xs"
color="primary"
:label="$t('timesheet.expense.type')" :label="$t('timesheet.expense.type')"
:menu-offset="[0, 10]"
menu-anchor="bottom middle"
menu-self="top middle"
popup-content-class="text-uppercase text-weight-bold text-center rounded-5"
popup-content-style="border: 2px solid var(--q-accent)"
:rules="[rules.typeRequired]" :rules="[rules.typeRequired]"
:option-label="label => $t(`timesheet.expense.types.${label}`)" :option-label="label => $t(`timesheet.expense.types.${label}`)"
/> />
<!-- amount input --> <!-- amount input -->
<template v-if="TYPES_WITH_AMOUNT_ONLY.includes(expenses_store.current_expense?.type ?? 'EXPENSES')"> <div v-if="TYPES_WITH_AMOUNT_ONLY.includes(expenses_store.current_expense?.type ?? 'EXPENSES')">
<q-input <q-input
key="amount" key="amount"
v-model.number="expenses_store.current_expense.amount" v-model.number="expenses_store.current_expense.amount"
@ -102,19 +121,23 @@
input-class="text-right" input-class="text-right"
dense dense
stack-label stack-label
clearable
color="primary" color="primary"
class="col q-px-xs" class="col q-px-xs"
:bg-color="background_color" label-slot
:label="$t('timesheet.expense.amount')"
suffix="$" suffix="$"
lazy-rules="ondemand" lazy-rules="ondemand"
:rules="[rules.amountRequired]" :rules="[rules.amountRequired]"
/> >
<template #label>
<span class="text-weight-bold text-accent text-uppercase text-caption">
{{ $t('timesheet.expense.amount') }}
</span>
</template> </template>
</q-input>
</div>
<!-- mileage input --> <!-- mileage input -->
<template v-else> <div v-else>
<q-input <q-input
key="mileage" key="mileage"
v-model.number="expenses_store.current_expense.mileage" v-model.number="expenses_store.current_expense.mileage"
@ -125,13 +148,18 @@
clearable clearable
color="primary" color="primary"
class="col q-px-xs" class="col q-px-xs"
:bg-color="background_color" label-slot
:label="$t('timesheet.expense.mileage')"
suffix="km" suffix="km"
lazy-rules="ondemand" lazy-rules="ondemand"
:rules="[rules.mileageRequired]" :rules="[rules.mileageRequired]"
/> >
<template #label>
<span class="text-weight-bold text-accent text-uppercase text-caption">
{{ $t('timesheet.expense.mileage') }}
</span>
</template> </template>
</q-input>
</div>
<!-- employee comment input --> <!-- employee comment input -->
<q-input <q-input
@ -139,19 +167,18 @@
filled filled
dense dense
stack-label stack-label
clearable label-slot
color="primary" color="primary"
type="text" type="text"
class="col q-px-sm" class="col q-px-sm"
:bg-color="background_color"
:counter="true" :counter="true"
:maxlength="COMMENT_MAX_LENGTH" :maxlength="COMMENT_MAX_LENGTH"
lazy-rules="ondemand" lazy-rules="ondemand"
:rules="[rules.commentRequired]" :rules="[rules.commentRequired]"
> >
<template #label> <template #label>
<span class="text-weight-bold "> <span class="text-weight-bold text-accent text-uppercase text-caption">
{{ $t('timesheet.expense.comment') }} {{ $t('timesheet.expense.employee_comment') }}
</span> </span>
</template> </template>
</q-input> </q-input>
@ -166,7 +193,6 @@
stack-label stack-label
:label="$t('timesheet.expense.hints.attach_file')" :label="$t('timesheet.expense.hints.attach_file')"
class="col" class="col"
:bg-color="background_color"
style="max-width: 300px;" style="max-width: 300px;"
> >
<template #prepend> <template #prepend>
@ -177,30 +203,29 @@
/> />
</template> </template>
</q-file> </q-file>
</div>
<div class="col row full-width items-center">
<q-space />
<!-- add btn section -->
<div class="col-auto column">
<q-btn <q-btn
v-if="expenses_store.mode === 'update'" v-if="expenses_store.mode === 'update'"
push flat
dense dense
size="sm" class="col-auto q-ml-sm"
class="col q-ml-sm" icon="clear"
icon="cancel" color="negative"
:label="$q.screen.gt.sm ? $t('shared.label.cancel') : ''"
@click="cancelUpdateMode" @click="cancelUpdateMode"
/> />
<q-btn <q-btn
push push
dense color="accent"
color="primary" :icon="expenses_store.mode === 'update' ? 'save' : 'upload'"
:icon="expenses_store.mode === 'update' ? 'save' : 'add'"
:label="$q.screen.gt.sm ? (expenses_store.mode === 'update' ? $t('shared.label.update') : $t('shared.label.add')) : ''" :label="$q.screen.gt.sm ? (expenses_store.mode === 'update' ? $t('shared.label.update') : $t('shared.label.add')) : ''"
size="sm" class="q-px-sm q-mb-sm q-mx-lg"
class="col q-mx-xs q-my-sm q-pr-sm"
type="submit" type="submit"
/> />
</div> </div>
</div>
</q-form> </q-form>
</template> </template>

View File

@ -2,43 +2,91 @@
setup setup
lang="ts" lang="ts"
> >
/* eslint-disable */ import { computed } from 'vue';
import { useExpensesStore } from 'src/stores/expense-store'; import { useExpensesStore } from 'src/stores/expense-store';
import { useTimesheetStore } from 'src/stores/timesheet-store';
const timesheet_store = useTimesheetStore();
const expense_store = useExpensesStore(); const expense_store = useExpensesStore();
const weekly_totals = computed(() => {
let expenses = 0;
let mileage = 0;
timesheet_store.timesheets.forEach(timesheet => {
expenses += timesheet.weekly_expenses.expenses ?? 0;
mileage += timesheet.weekly_expenses.mileage ?? 0;
});
return { expenses, mileage };
});
</script> </script>
<template> <template>
<q-item class="row justify-between items-center q-pa-none"> <div class="column items-center q-pa-none">
<q-item-label <div class="col row full-width">
header <q-item-label class="col text-h6 text-weight-bolder text-uppercase q-py-sm q-px-md">
class="text-h6 col q-pa-none"
>
{{ $t('timesheet.expense.title') }} {{ $t('timesheet.expense.title') }}
</q-item-label> </q-item-label>
<!-- <q-item-section <q-space />
no-wrap
class="col-auto items-center"
>
<q-badge
outline
class="q-py-xs q-px-md"
color="primary"
:label="$t('timesheet.expense.total_amount') + ': $' + expense_store.pay_period_expenses?.toFixed(2)"
/>
</q-item-section>
<q-item-section <q-btn
no-wrap square
class="col-auto items-center" icon="clear"
> color="negative"
<q-badge class="col-auto"
outline style="border-radius: 0 0 0 5px;"
class="q-py-xs q-px-md" @click="expense_store.close"
color="primary"
:label="$t('timesheet.expense.total_mileage') + ': ' + expense_store.pay_period_expenses?.total_mileage.toFixed(1) + ' km'"
/> />
</q-item-section> --> </div>
</q-item>
<div class="col column items-end full-width q-pt-sm q-px-md">
<div class="col-auto row items-center q-px-sm">
<span
v-if="$q.screen.gt.sm"
class="col-auto text-uppercase text-weight-light text-accent q-mr-xs"
>
{{ $t('timesheet.expense.total_amount') }} :
</span>
<q-icon
v-else
name="payments"
size="sm"
color="accent"
class="col"
/>
<span
class="col-auto text-weight-light"
style="font-size: 2.5em; line-height: 1em;"
>
{{ weekly_totals.expenses.toFixed(2) }}
</span>
</div>
<div class="col-auto row items-center q-px-sm">
<span
v-if="$q.screen.gt.sm"
class="col text-uppercase text-weight-light text-accent q-mr-xs"
>
{{ $t('timesheet.expense.total_mileage') }} :
</span>
<q-icon
v-else
name="drive_eta"
size="sm"
color="accent"
class="col"
/>
<span
class="col-auto text-weight-light"
style="font-size: 2.5em; line-height: 1em;"
>
{{ weekly_totals.mileage.toFixed(1) }}
</span>
</div>
</div>
</div>
</template> </template>

View File

@ -4,13 +4,13 @@
> >
import { computed, inject, ref } from 'vue'; import { computed, inject, 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 { useAuthStore } from 'src/stores/auth-store'; import { useAuthStore } from 'src/stores/auth-store';
import { CAN_APPROVE_PAY_PERIODS } from 'src/modules/shared/models/user.models'; import { CAN_APPROVE_PAY_PERIODS } from 'src/modules/shared/models/user.models';
import { Expense } from 'src/modules/timesheets/models/expense.models'; import { Expense } from 'src/modules/timesheets/models/expense.models';
import { deepEqual } from 'src/utils/deep-equal';
const { expense, horizontal = false } = defineProps<{ const { expense, horizontal = false } = defineProps<{
expense: Expense; expense: Expense;
@ -25,18 +25,11 @@
const employee_email = inject<string>('employeeEmail') ?? ''; const employee_email = inject<string>('employeeEmail') ?? '';
const refresh_key = ref(1); const refresh_key = ref(1);
const background_class = computed(() => deepEqual(expense, expenses_store.current_expense) ? 'bg-accent' : ''); const background_class = computed(() => deepEqual(expense, expenses_store.current_expense) ? '' : '');
const approved_class = computed(() => horizontal ? ' q-mx-xs q-pa-xs cursor-pointer' : '') const background_style = computed(() => deepEqual(expense, expenses_store.current_expense) ? 'border: 3px solid var(--q-accent);' : '');
const expense_item_style = computed(() => is_approved.value ? 'border: solid 2px var(--q-primary);' : 'border: solid 2px grey;'); 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_authorized_to_approve = computed(() => CAN_APPROVE_PAY_PERIODS.includes(auth_store.user?.role ?? 'GUEST'))
const setExpenseToUpdate = () => {
expenses_store.mode = 'update';
// if (expense.is_approved) return;
expenses_store.current_expense = expense;
expenses_store.initial_expense = unwrapAndClone(expense);
};
const requestExpenseDeletion = async () => { const requestExpenseDeletion = async () => {
// expenses_store.mode = 'delete'; // expenses_store.mode = 'delete';
expenses_store.initial_expense = expense; expenses_store.initial_expense = expense;
@ -52,13 +45,15 @@
} }
const onUpdateClicked = () => { const onUpdateClicked = () => {
if (expenses_store.mode === 'update') { if (deepEqual(expense, expenses_store.current_expense)){
expenses_store.mode = 'create'; expenses_store.mode = 'create';
expenses_store.current_expense = new Expense; expenses_store.current_expense = new Expense;
expenses_store.initial_expense = new Expense;
return; return;
} }
setExpenseToUpdate();
expenses_store.mode = 'update';
expenses_store.current_expense = expense;
expenses_store.initial_expense = unwrapAndClone(expense);
} }
</script> </script>
@ -70,28 +65,16 @@
<q-item <q-item
:key="refresh_key" :key="refresh_key"
:clickable="horizontal" :clickable="horizontal"
class="row col-4 q-ma-xs shadow-2" class="row col-4 items-center q-my-sm q-py-none shadow-3 rounded-5 bg-dark"
:class="background_class + approved_class" :class="background_class + approved_class"
:style="expense_item_style" :style="background_style"
@click="onExpenseClicked" @click="onExpenseClicked"
> >
<q-badge
v-if="expense.is_approved"
class="absolute z-top rounded-20 bg-dark q-pa-none"
style="transform: translate(-15px, -15px);"
>
<q-icon
name="verified"
color="primary"
size="md"
/>
</q-badge>
<!-- avatar type icon section --> <!-- avatar type icon section -->
<q-item-section avatar> <q-item-section avatar>
<q-icon <q-icon
:name="getExpenseIcon(expense.type)" :name="getExpenseIcon(expense.type)"
:color="expense.is_approved ? 'primary' : ($q.dark.isActive ? 'blue-grey-2' : 'grey-8')" :color="expense.is_approved ? 'white' : ($q.dark.isActive ? 'blue-grey-2' : 'primary')"
size="lg" size="lg"
> >
<q-badge <q-badge
@ -102,14 +85,14 @@
<q-icon <q-icon
name="shield" name="shield"
size="xs" size="xs"
:color="expense.is_approved ? 'primary' : ($q.dark.isActive ? 'blue-grey-2' : 'grey-8')" :color="expense.is_approved ? 'white' : ($q.dark.isActive ? 'blue-grey-2' : 'primary')"
/> />
</q-badge> </q-badge>
</q-icon> </q-icon>
</q-item-section> </q-item-section>
<!-- amount or mileage section --> <!-- amount or mileage section -->
<q-item-section class="col col-md-2"> <q-item-section class="col col-md-2 text-weight-bold">
<q-item-label v-if="String(expense.type).trim().toUpperCase() === 'MILEAGE'"> <q-item-label v-if="String(expense.type).trim().toUpperCase() === 'MILEAGE'">
<template v-if="typeof expense.mileage === 'number'"> <template v-if="typeof expense.mileage === 'number'">
{{ expense.mileage?.toFixed(1) }} km {{ expense.mileage?.toFixed(1) }} km
@ -126,7 +109,8 @@
<q-item-label <q-item-label
caption caption
lines="1" lines="1"
class="text-uppercase" class="text-uppercase text-weight-light"
:class="approved_class"
> >
{{ $d(new Date(expense.date), { month: 'short', day: 'numeric', weekday: 'long' }) }} {{ $d(new Date(expense.date), { month: 'short', day: 'numeric', weekday: 'long' }) }}
</q-item-label> </q-item-label>
@ -135,28 +119,32 @@
<q-space v-if="horizontal" /> <q-space v-if="horizontal" />
<!-- attachment file icon --> <!-- attachment file icon -->
<q-item-section side> <q-item-section avatar>
<q-btn <q-btn
push push
dense :color="expense.is_approved ? 'white' : 'accent'"
size="md" :text-color="expense.is_approved ? 'accent' : 'white'"
color="primary" class="col-auto q-mx-sm q-px-sm q-pb-sm"
class="q-mx-lg"
icon="attach_file" icon="attach_file"
/> />
</q-item-section> </q-item-section>
<q-item-label class="col text-weight-light text-caption">
<span>attachment_goes_here.jpg</span>
</q-item-label>
<!-- comment section --> <!-- comment section -->
<q-item-section <q-item-section
v-if="!horizontal" v-if="!horizontal"
top top
> >
<q-item-label lines="1"> <q-item-label lines="1" class="text-weight-medium text-uppercase">
{{ $t('timesheet.expense.employee_comment') }} {{ $t('timesheet.expense.employee_comment') }}
</q-item-label> </q-item-label>
<q-item-label <q-item-label
caption caption
lines="1" lines="1"
:class="approved_class"
> >
{{ expense.comment }} {{ expense.comment }}
</q-item-label> </q-item-label>
@ -164,10 +152,10 @@
<!-- supervisor comment section --> <!-- supervisor comment section -->
<q-item-section <q-item-section
v-if="expense.supervisor_comment && !horizontal" v-if="is_authorized_to_approve"
top top
> >
<q-item-label lines="1"> <q-item-label lines="1" class="text-weight-medium text-uppercase">
{{ $t('timesheet.expense.supervisor_comment') }} {{ $t('timesheet.expense.supervisor_comment') }}
</q-item-label> </q-item-label>
<q-item-label <q-item-label
@ -181,22 +169,23 @@
<q-item-section :side="$q.screen.gt.sm"> <q-item-section :side="$q.screen.gt.sm">
<q-btn <q-btn
push flat
dense color="accent"
color="primary"
icon="edit" icon="edit"
class="z-top" size="lg"
class="q-pa-none z-top"
:class="expense.is_approved ? 'invisible no-pointer' : ''"
@click.stop="onUpdateClicked" @click.stop="onUpdateClicked"
/> />
</q-item-section> </q-item-section>
<q-item-section :side="$q.screen.gt.sm"> <q-item-section :side="$q.screen.gt.sm">
<q-btn <q-btn
push flat
dense :color="expense.is_approved ? 'white' : 'negative'"
color="negative" :icon="expense.is_approved ? 'verified' : 'close'"
icon="close" size="lg"
class="z-top" class="q-pa-none z-top"
@click.stop="requestExpenseDeletion" @click.stop="requestExpenseDeletion"
/> />
</q-item-section> </q-item-section>

View File

@ -16,7 +16,7 @@
<!-- liste des dépenses pré existantes --> <!-- liste des dépenses pré existantes -->
<q-list <q-list
padding padding
class="rounded-borders" class="q-px-lg"
:class="horizontal ? 'row flex-center' : ''" :class="horizontal ? 'row flex-center' : ''"
> >
<q-item-label <q-item-label

View File

@ -14,16 +14,18 @@
<q-dialog <q-dialog
v-model="expense_store.is_open" v-model="expense_store.is_open"
persistent persistent
transition-show="jump-down"
transition-hide="jump-down"
> >
<q-card <q-card
class="q-pa-md" class="q-pa-none rounded-10 shadow-10 bg-secondary"
style=" min-width: 70vw;" style=" min-width: 70vw;"
> >
<q-inner-loading :showing="expense_store.is_loading"> <q-inner-loading :showing="expense_store.is_loading">
<q-spinner size="32px" /> <q-spinner size="32px" />
</q-inner-loading> </q-inner-loading>
<q-card-section> <q-card-section class="q-pa-none">
<!-- <q-banner <!-- <q-banner
v-if="expenses_error" v-if="expenses_error"
dense dense
@ -50,22 +52,7 @@
size="lg" size="lg"
/> />
</transition> </transition>
<q-separator spaced />
</q-card-section> </q-card-section>
<q-card-actions align="right">
<!-- close btn -->
<q-btn
flat
class="col-auto q-mr-sm"
color="primary"
:label="$t('shared.label.close')"
@click="expense_store.close"
/>
</q-card-actions>
</q-card> </q-card>
</q-dialog> </q-dialog>
</template> </template>

View File

@ -0,0 +1,53 @@
<script
setup
lang="ts"
>
import { computed } from 'vue';
import { date, useQuasar } from 'quasar';
const q = useQuasar();
const { extractDate } = date;
const { displayDate, dense = false, approved = false} = defineProps<{
displayDate: string;
dense?: boolean;
approved?: boolean;
}>();
const is_mobile = computed(() => q.screen.lt.md);
const date_font_size = computed(() => dense ? '1.5em' : '2.5em');
const weekday_font_size = computed(() => dense ? '0.55em;' : '0.7em;');
const date_box_size = computed(() => dense || is_mobile.value ? 'width: 40px; height: 75px;' : 'width: 75px; height: 75px;');
const display_date = extractDate(displayDate, 'YYYY-MM-DD');
</script>
<template>
<div
class="column flex-center rounded-10 text-center self-center bg-transparent"
:style="date_box_size"
>
<span
v-if="!dense"
class="col-auto text-uppercase text-weight-bold"
:class="approved ? 'text-white' : ''"
:style="'font-size: ' + weekday_font_size"
>
{{ $d(display_date, { weekday: $q.screen.lt.md ? 'short' : 'long'}) }}
</span>
<span
class="col-auto text-weight-bolder"
:class="approved ? 'text-white' : ''"
:style="'font-size: ' + date_font_size + '; line-height: 90% !important;'"
>
{{ display_date.getDate() }}
</span>
<span
class="col-auto text-uppercase text-weight-bold"
:class="approved ? 'text-white' : ''"
:style="'font-size: ' + weekday_font_size"
>
{{ $d(display_date, { month: $q.screen.lt.md ? 'short' : 'long' }) }}
</span>
</div>
</template>

View File

@ -0,0 +1,266 @@
<script
setup
lang="ts"
>
/* eslint-disable*/
import { onBeforeUnmount, onMounted, ref, toRaw, useTemplateRef } from 'vue';
import { unwrapAndClone } from 'src/utils/unwrap-and-clone';
import { useI18n } from 'vue-i18n';
import { QSelect } from 'quasar';
import { Shift, ShiftType } from 'src/modules/timesheets/models/shift.models';
import { useUiStore } from 'src/stores/ui-store';
const { t } = useI18n();
const ui_store = useUiStore();
interface ShiftOption {
label: string;
value: ShiftType;
icon: string;
icon_color: string;
}
const COMMENT_LENGTH_MAX = 280;
const SHIFT_OPTIONS: ShiftOption[] = [
{ label: t('timesheet.shift.types.REGULAR'), value: 'REGULAR', icon: 'wb_sunny', icon_color: '' },
{ 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: 'cyan-8' },
];
const shift = defineModel<Shift>('shift', { required: true });
const { dense = false, outlined = false } = defineProps<{
dense?: boolean;
outlined?: boolean;
}>();
const emit = defineEmits<{
'saveComment': [comment: string, shift_id: number];
'requestDelete': [void];
}>();
const is_showing_time_picker = ref(false);
const select_ref = useTemplateRef<QSelect>('select');
const initial_shift = ref<Shift>(unwrapAndClone(toRaw(shift.value)))
let timer: NodeJS.Timeout;
const shift_type_selected = ref(SHIFT_OPTIONS.find(option => option.value == shift.value.type));
const onBlurShiftTypeSelect = () => {
if (shift_type_selected.value === undefined) {
shift.value.type = 'REGULAR';
shift.value.id = 0;
emit('requestDelete');
}
};
const slideDeleteShift = async (reset: () => void) => {
timer = setTimeout(() => {
reset();
emit('requestDelete');
}, 200);
};
const getCommentCounterColor = (comment_length: number) => {
if (comment_length < 200) return 'primary';
if (comment_length < 250) return 'warning';
return 'negative';
};
onMounted(() => {
if (ui_store.focus_next_component) {
select_ref.value?.focus();
select_ref.value?.showPopup();
shift_type_selected.value = undefined;
ui_store.focus_next_component = false;
}
});
onBeforeUnmount(() => {
clearTimeout(timer);
});
</script>
<template>
<q-slide-item
right-color="negative"
class="q-my-xs rounded-5 bg-transparent"
@right="details => slideDeleteShift(details.reset)"
>
<template
#right
v-if="ui_store.is_mobile_mode"
>
<q-icon name="delete" />
</template>
<div
class="row flex-center text-uppercase rounded-5 bg-transparent"
>
<!-- shift type -->
<q-select
ref="select"
v-model="shift_type_selected"
standout="bg-blue-grey-9"
dense
:readonly="shift.is_approved"
:options-dense="!ui_store.is_mobile_mode"
hide-dropdown-icon
:menu-offset="[0, 10]"
menu-anchor="bottom middle"
menu-self="top middle"
:options="SHIFT_OPTIONS"
class="rounded-5 q-mx-xs bg-dark"
:class="(ui_store.is_mobile_mode ? 'col-12 q-mb-xs ' : 'col ')"
popup-content-class="text-uppercase text-weight-bold text-center rounded-5"
popup-content-style="border: 2px solid var(--q-accent)"
@blur="onBlurShiftTypeSelect"
@update:model-value="option => shift.type = option.value"
>
<template #selected-item="scope">
<div
class="row flex-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'"
:tabindex="scope.tabindex"
>
<q-icon
:name="scope.opt.icon"
:color="scope.opt.icon_color"
size="sm"
class="col-auto q-mx-xs"
/>
<span
style="line-height: 0.9em;"
class="col-auto ellipsis"
>{{ scope.opt.label }}</span>
</div>
</template>
</q-select>
<!-- punch in field -->
<q-input
v-model="shift.start_time"
dense
:readonly="shift.is_approved"
type="time"
:standout="$q.dark.isActive ? 'bg-blue-grey-9' : 'bg-blue-grey-1 text-white'"
label-slot
label-color="accent"
:input-class="'text-weight-medium ' + (shift.id === -2 ? 'text-white ' : ' ') + (shift.is_approved ? 'cursor-not-allowed' : '')"
input-style="font-size: 1.2em;"
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' : '')"
>
<template #label>
<span
class="text-weight-bolder"
style="font-size: 0.95em;"
>{{ $t('shared.misc.in') }}</span>
</template>
</q-input>
<!-- punch out field -->
<q-input
v-model="shift.end_time"
dense
:readonly="shift.is_approved"
type="time"
standout="bg-blue-grey-9"
label-slot
label-color="accent"
:input-class="'text-weight-medium ' + (shift.id === -2 ? 'text-white ' : ' ') + (shift.is_approved ? 'cursor-not-allowed' : '')"
input-style="font-size: 1.2em;"
class="col rounded-5 bg-dark"
:class="(shift.id === -2 ? 'bg-negative ' : ' ') + (ui_store.is_mobile_mode ? 'q-ml-xs ' : 'q-mx-xs ') + (shift.is_approved ? 'cursor-not-allowed' : '')"
>
<template #label>
<span
class="text-weight-bolder"
style="font-size: 0.95em;"
>{{ $t('shared.misc.out') }}</span>
</template>
</q-input>
<!-- comment and delete buttons -->
<div :class="ui_store.is_mobile_mode ? 'col-12 row' : 'col-auto'">
<q-icon
v-if="shift.type && dense"
:name="shift.comment ? 'comment' : ''"
color="primary"
:size="dense ? 'xs' : 'sm'"
class="col"
/>
<q-btn
v-else
flat
dense
:icon="shift.comment ? 'chat' : 'chat_bubble_outline'"
:text-color="shift.comment ? 'accent' : 'grey-5'"
class="col"
:class="ui_store.is_mobile_mode ? 'q-mt-xs bg-dark' : ''"
>
<q-popup-edit
v-model="shift.comment"
:title="$t('timesheet.shift.fields.header_comment')"
auto-save
v-slot="scope"
class="bg-dark"
>
<q-input
color="white"
v-model="scope.value"
dense
:readonly="shift.is_approved"
autofocus
counter
bottom-slots
:maxlength="COMMENT_LENGTH_MAX"
class="q-pb-lg"
:class="shift.is_approved ? 'cursor-not-allowed' : ''"
@keyup.enter="scope.set"
>
<template #append>
<q-icon name="edit" />
</template>
<template #counter>
<div class="row flex-center">
<q-space />
<q-knob
:model-value="scope.value?.length"
readonly
:max="COMMENT_LENGTH_MAX"
size="1.6em"
:thickness="0.4"
:color="getCommentCounterColor(scope.value?.length ?? 0)"
track-color="grey-4"
class="col-auto q-mr-xs"
/>
<span
:class="'col-auto text-weight-bolder text-' + getCommentCounterColor(scope.value?.length ?? 0)"
>{{ 280 - (scope.value?.length ?? 0) }}</span>
</div>
</template>
</q-input>
</q-popup-edit>
</q-btn>
<q-btn
v-if="!ui_store.is_mobile_mode"
flat
dense
:disable="shift.is_approved"
tabindex="-1"
icon="cancel"
text-color="negative"
class="col"
:class="shift.is_approved ? 'invisible' : ''"
@click="$emit('requestDelete')"
/>
</div>
</div>
</q-slide-item>
</template>

View File

@ -0,0 +1,44 @@
<script
setup
lang="ts"
>
import ShiftListDayRow from 'src/modules/timesheets/components/shift-list-day-row.vue';
import { useShiftApi } from 'src/modules/timesheets/composables/use-shift-api';
import type { Shift } from 'src/modules/timesheets/models/shift.models';
import type { TimesheetDay } from 'src/modules/timesheets/models/timesheet.models';
const shift_api = useShiftApi();
const { day, dense = false, outlined = false, approved = false } = defineProps<{
day: TimesheetDay;
dense?: boolean;
outlined?: boolean;
approved?: boolean;
}>();
const emit = defineEmits<{
'deleteUnsavedShift': [void];
}>();
const deleteCurrentShift = async (shift: Shift) => {
if (shift.id <= 0) {
shift.id = 0;
emit('deleteUnsavedShift');
return;
}
await shift_api.deleteShiftById(shift.id);
};
</script>
<template>
<div class="column justify-center q-py-xs" :class="approved ? 'bg-dark' : ''">
<ShiftListDayRow
v-for="shift, shift_index in day.shifts"
:key="shift_index"
v-model:shift="day.shifts[shift_index]!"
:outlined="outlined"
:dense="dense"
@request-delete="deleteCurrentShift(shift)"
/>
</div>
</template>

View File

@ -1,208 +0,0 @@
<script
setup
lang="ts"
>
/* eslint-disable*/
import { onMounted, ref, useTemplateRef } from 'vue';
import { useI18n } from 'vue-i18n';
import { QSelect } from 'quasar';
import { Shift, ShiftType } from 'src/modules/timesheets/models/shift.models';
import { useUiStore } from 'src/stores/ui-store';
const { t } = useI18n();
const ui_store = useUiStore();
const shift = defineModel<Shift>('shift', { required: true });
const { dense = false } = defineProps<{
dense?: boolean;
}>();
defineEmits<{
'saveComment': [comment: string, shift_id: number];
'requestUpdate': [shift_id: number];
'requestDelete': [void];
}>();
const time_picker_model = ref('');
const is_showing_time_picker = ref(false);
const select_ref = useTemplateRef<QSelect>('select');
const options: { label: string, value: ShiftType, icon: string, icon_color: string }[] = [
{ label: t('timesheet.shift.types.REGULAR'), value: 'REGULAR', icon: 'wb_sunny', icon_color: '' },
{ 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: 'cyan-8' },
];
const shift_type_selected = ref(options.find(option => option.value == shift.value.type));
const showTimePicker = (time: string) => {
is_showing_time_picker.value = true;
time_picker_model.value = time;
};
const onBlurShiftTypeSelect = () => {
if (shift_type_selected.value === undefined) {
shift.value.shift_id = 0;
}
}
onMounted(() => {
if (ui_store.focus_next_component) {
select_ref.value?.focus();
select_ref.value?.showPopup();
shift_type_selected.value = undefined;
ui_store.focus_next_component = false;
}
});
</script>
<template>
<div
v-if="shift.shift_id !== 0"
class="row col flex-center text-uppercase rounded-10"
:class="$q.screen.lt.md ? 'q-pa-xs' : ''"
>
<!-- shift type -->
<q-select
ref="select"
v-model="shift_type_selected"
standout="bg-blue-grey-9"
dense
:options-dense="!ui_store.is_mobile_mode"
hide-dropdown-icon
:menu-offset="[0, 10]"
menu-anchor="bottom middle"
menu-self="top middle"
:options="options"
class="rounded-5 shadow-1"
:class="ui_store.is_mobile_mode ? 'col-auto q-mx-xs' : 'col q-mx-xs'"
popup-content-class="text-uppercase text-weight-bold text-center rounded-5"
popup-content-style="border: 2px solid var(--q-primary)"
@blur="onBlurShiftTypeSelect"
>
<template #selected-item="scope">
<div
class="row text-weight-bold q-ma-none q-pa-none no-wrap ellipsis"
:class="ui_store.is_mobile_mode ? 'items-center full-height' : 'flex-center'"
:tabindex="scope.tabindex"
>
<q-icon
:name="scope.opt.icon"
:color="scope.opt.icon_color"
size="sm"
class="col-auto q-mx-xs"
/>
<span
v-if="$q.screen.gt.md"
style="line-height: 0.9em;"
class="col ellipsis"
>{{ scope.opt.label }}</span>
</div>
</template>
</q-select>
<!-- punch in field -->
<q-input
v-model="shift.start_time"
dense
type="time"
standout="bg-blue-grey-9"
label-slot
label-color="primary"
input-class="text-weight-medium"
input-style="font-size: 1.2em;"
class="col q-mx-xs"
>
<template #label>
<span
class="text-weight-bolder"
style="font-size: 0.95em;"
>{{ $t('shared.misc.in') }}</span>
</template>
<template #append>
<q-btn
v-if="ui_store.is_mobile_mode"
dense
flat
icon="access_time"
color="primary"
@click.stop="showTimePicker(shift.start_time)"
/>
</template>
</q-input>
<!-- punch out field -->
<q-input
v-model="shift.end_time"
dense
type="time"
standout="bg-blue-grey-9"
label-slot
label-color="primary"
input-class="text-weight-medium"
input-style="font-size: 1.2em;"
class="col q-mx-xs"
lazy-rules
>
<template #label>
<span
class="text-weight-bolder"
style="font-size: 0.95em;"
>{{ $t('shared.misc.out') }}</span>
</template>
<template #append>
<q-btn
v-if="ui_store.is_mobile_mode"
dense
flat
icon="access_time"
color="primary"
@click="showTimePicker(shift.end_time)"
/>
</template>
</q-input>
<!-- comment and delete buttons -->
<div
v-if="$q.screen.gt.sm"
class="col-auto"
>
<q-icon
v-if="shift.type && dense"
:name="shift.comment ? 'comment' : ''"
color="primary"
:size="dense ? 'xs' : 'sm'"
class="col-auto q-pa-none"
/>
<q-btn
v-else
flat
dense
:icon="shift.comment ? 'chat' : 'chat_bubble_outline'"
:text-color="shift.comment ? 'primary' : 'grey-8'"
class="col-auto q-ma-none full-height"
/>
<q-btn
dense
flat
round
unelevated
tabindex="-1"
icon="cancel"
color="negative"
class="q-pa-none q-mr-xs"
@click="$emit('requestDelete')"
/>
</div>
</div>
</template>

View File

@ -3,27 +3,17 @@
lang="ts" lang="ts"
> >
import { date } from 'quasar'; import { date } from 'quasar';
import { computed } from 'vue';
import { useQuasar } from 'quasar';
import ShiftListRow from 'src/modules/timesheets/components/shift-list-row.vue';
import { useTimesheetStore } from 'src/stores/timesheet-store';
import { useShiftApi } from 'src/modules/timesheets/composables/use-shift-api';
import { Shift } from 'src/modules/timesheets/models/shift.models';
import { useUiStore } from 'src/stores/ui-store'; import { useUiStore } from 'src/stores/ui-store';
import { useTimesheetStore } from 'src/stores/timesheet-store';
import { Shift } from 'src/modules/timesheets/models/shift.models';
import ShiftListDay from 'src/modules/timesheets/components/shift-list-day.vue';
import ShiftListDateWidget from 'src/modules/timesheets/components/shift-list-date-widget.vue';
import type { TimesheetDay } from 'src/modules/timesheets/models/timesheet.models';
const { extractDate } = date;
const q = useQuasar();
const ui_store = useUiStore(); const ui_store = useUiStore();
const timesheet_store = useTimesheetStore(); const timesheet_store = useTimesheetStore();
const shift_api = useShiftApi();
const { dense = false } = defineProps<{
dense?: boolean;
}>();
const is_mobile = computed(() => q.screen.lt.md);
const date_font_size = computed(() => dense ? '1.5em' : '2.5em');
const weekday_font_size = computed(() => dense ? '0.55em;' : '0.7em;');
const date_box_size = computed(() => dense || is_mobile.value ? 'width: 40px; height: 75px;' : 'width: 75px; height: 75px;');
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;
@ -33,13 +23,18 @@
day_shifts.push(new_shift); day_shifts.push(new_shift);
}; };
const deleteCurrentShift = async (shift: Shift) => { const deleteUnsavedShift = (timesheet_index: number, day_index: number) => {
console.log('shift to delete: ', shift); if (timesheet_store.timesheets !== undefined) {
if (shift.shift_id < 0) { const day = timesheet_store.timesheets[timesheet_index]!.days[day_index]!;
shift.shift_id = 0; const shifts_without_deleted_shift = day.shifts.filter(shift => shift.id !== 0);
return; day.shifts = shifts_without_deleted_shift;
console.log("day's shifts after cleanup: ", day.shifts);
} }
await shift_api.deleteShiftById(shift.shift_id); }
const getDayApproval = (day: TimesheetDay) => {
if (day.shifts.length < 1) return false;
return day.shifts.every(shift => shift.is_approved === true);
} }
</script> </script>
@ -49,74 +44,114 @@
:style="$q.screen.lt.md ? 'width: 90vw !important;' : ''" :style="$q.screen.lt.md ? 'width: 90vw !important;' : ''"
> >
<div <div
v-for="timesheet in timesheet_store.timesheets" v-for="timesheet, timesheet_index in timesheet_store.timesheets"
:key="timesheet.timesheet_id" :key="timesheet.timesheet_id"
class="col column" class="col column"
> >
<div <div
v-for="day in timesheet.days" v-for="day, day_index in timesheet.days"
:key="day.date" :key="day.date"
class="col-auto row shadow-2 rounded-10 q-ma-xs" class="col-auto row rounded-10 q-ma-sm shadow-10"
>
<div
v-if="ui_store.is_mobile_mode"
class="col column full-width"
>
<q-card class="rounded-5 q-my-md" :class="getDayApproval(day) ? 'bg-accent' : 'bg-dark'">
<q-card-section
class="text-white text-weight-bolder text-uppercase text-h6 q-py-xs"
:class="getDayApproval(day) ? 'bg-dark' : 'bg-accent'"
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
:approved="getDayApproval(day)"
: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)"
push
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)"
floating
class="bg-secondary q-pa-none rounded-50"
style="transform: translate(15px, -5px);"
>
<q-icon
name="verified"
size="5em"
color="accent"
/>
</q-badge>
</q-card>
</div>
<div
v-else
class="col row full-width"
:class="getDayApproval(day) ? 'rounded-10 bg-accent' : ''"
> >
<div <div
class="col row bg-dark" class="col row bg-dark"
:class="getDayApproval(day) ? 'bg-transparent' : ''"
style="border-radius: 10px 0 0 10px;" style="border-radius: 10px 0 0 10px;"
> >
<!-- Dates column --> <!-- Date block -->
<div <ShiftListDateWidget
class="col-auto column flex-center bg-primary rounded-10 text-center q-ma-sm self-center" :display-date="day.date"
:class="$q.screen.lt.md ? '' : ''" :approved="getDayApproval(day)"
:style="date_box_size" class="col-auto"
> />
<span
v-if="!dense" <!-- List of shifts -->
class="col-auto text-uppercase text-white" <ShiftListDay
:style="'font-size: ' + weekday_font_size" :day="day"
> class="col"
{{ $d(date.extractDate(day.date, 'YYYY-MM-DD'), { @delete-unsaved-shift="deleteUnsavedShift(timesheet_index, day_index)"
weekday: $q.screen.lt.md ? 'short' :
'long'
})
}}
</span>
<span
class="col-auto text-weight-bolder text-grey-1"
:style="'font-size: ' + date_font_size + '; line-height: 90% !important;'"
>
{{ date.extractDate(day.date, 'YYYY-MM-DD').getDate() }}
</span>
<span
class="col-auto text-uppercase text-white"
:style="'font-size: ' + weekday_font_size"
>
{{ $d(date.extractDate(day.date, 'YYYY-MM-DD'), {
month: $q.screen.lt.md ? 'short' : 'long'
})
}}
</span>
</div>
<!-- List of shifts column -->
<div class="col column">
<ShiftListRow
v-for="shift, shift_index in day.shifts"
:key="shift_index"
v-model:shift="day.shifts[shift_index]!"
:dense="dense"
@request-delete="deleteCurrentShift(shift)"
/> />
</div>
</div> </div>
<div class="col-auto self-stretch"> <div class="col-auto self-stretch">
<q-icon
v-if="getDayApproval(day)"
name="verified"
color="white"
size="xl"
class="full-height"
/>
<q-btn <q-btn
unelevated v-else
:dense="!ui_store.is_mobile_mode"
icon="more_time" icon="more_time"
:size="$q.screen.lt.md ? 'md' : 'lg'" size="lg"
color="primary" color="accent"
text-color="white" text-color="white"
class="full-height" class="full-height"
:class="$q.screen.lt.md ? 'q-px-xs' : ''" :class="$q.screen.lt.md ? 'q-px-xs ' : ' '"
style="border-radius: 0 10px 10px 0;" style="border-radius: 0 10px 10px 0;"
@click="addNewShift(day.shifts, day.date, timesheet.timesheet_id)" @click="addNewShift(day.shifts, day.date, timesheet.timesheet_id)"
/> />
@ -124,4 +159,5 @@
</div> </div>
</div> </div>
</div> </div>
</div>
</template> </template>

View File

@ -0,0 +1,56 @@
<script
setup
lang="ts"
>
import { useShiftStore } from 'src/stores/shift-store';
import { computed } from 'vue';
const shift_store = useShiftStore();
const is_showing_errors = computed(() => shift_store.shift_errors.length > 0);
</script>
<template>
<q-slide-transition>
<div
clickable
v-if="is_showing_errors"
class="full-width q-px-md q-mb-sm"
@click="shift_store.shift_errors = []"
>
<q-list>
<q-item
clickable
dense
v-for="error, index in shift_store.shift_errors"
:key="index"
class="row items-center full-width bg-dark shadow-2 rounded-5 q-my-xs"
style="border: 2px solid var(--q-negative)"
>
<q-item-section class="col-auto">
<q-badge
outline
color="negative"
class="bg-dark text-weight-bolder"
>{{ error.conflicts.date }}</q-badge>
</q-item-section>
<q-item-section class="col-auto">
<q-badge
outline
color="negative"
class="bg-dark text-weight-bolder"
>
{{ error.conflicts.start_time }} - {{ error.conflicts.end_time }}
</q-badge>
</q-item-section>
<q-item-label class="text-weight-medium text-caption q-ml-md">
{{ $t('timesheet.shift.errors.' + error.error_code) }}
</q-item-label>
</q-item>
</q-list>
</div>
</q-slide-transition>
</template>

View File

@ -5,11 +5,12 @@
import ShiftList from 'src/modules/timesheets/components/shift-list.vue'; import ShiftList from 'src/modules/timesheets/components/shift-list.vue';
import ExpenseDialog from 'src/modules/timesheets/components/expense-dialog.vue'; import ExpenseDialog from 'src/modules/timesheets/components/expense-dialog.vue';
import PayPeriodNavigator from 'src/modules/shared/components/pay-period-navigator.vue'; import PayPeriodNavigator from 'src/modules/shared/components/pay-period-navigator.vue';
import TimesheetErrorWidget from 'src/modules/timesheets/components/timesheet-error-widget.vue';
import { useTimesheetStore } from 'src/stores/timesheet-store'; import { useTimesheetStore } from 'src/stores/timesheet-store';
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 { provide } from 'vue'; import { provide } from 'vue';
import { useShiftApi } from 'src/modules/timesheets/composables/use-shift-api'; import { useShiftApi } from 'src/modules/timesheets/composables/use-shift-api';
const { open } = useExpensesStore(); const { open } = useExpensesStore();
const shift_api = useShiftApi(); const shift_api = useShiftApi();
@ -81,7 +82,7 @@ import { useShiftApi } from 'src/modules/timesheets/composables/use-shift-api';
push push
rounded rounded
:disable="timesheet_store.is_loading" :disable="timesheet_store.is_loading"
color="primary" color="accent"
icon="upload" icon="upload"
:label="$t('shared.label.save')" :label="$t('shared.label.save')"
class="q-mr-md" class="q-mr-md"
@ -93,7 +94,7 @@ import { useShiftApi } from 'src/modules/timesheets/composables/use-shift-api';
v-if="$q.screen.gt.sm" v-if="$q.screen.gt.sm"
push push
rounded rounded
color="primary" color="accent"
icon="receipt_long" icon="receipt_long"
:label="$t('timesheet.expense.open_btn')" :label="$t('timesheet.expense.open_btn')"
@click="open" @click="open"
@ -101,6 +102,10 @@ import { useShiftApi } from 'src/modules/timesheets/composables/use-shift-api';
</q-card-section> </q-card-section>
<q-card-section class="q-pa-none">
<TimesheetErrorWidget />
</q-card-section>
<ShiftList :dense="dense" /> <ShiftList :dense="dense" />
</q-card> </q-card>
<ExpenseDialog /> <ExpenseDialog />

View File

@ -20,15 +20,14 @@ export const useShiftApi = () => {
const saveShiftChanges = async () => { const saveShiftChanges = async () => {
timesheet_store.is_loading = true; timesheet_store.is_loading = true;
const create_success = await shift_store.createNewShifts();
if (create_success) { const create_success = await shift_store.createNewShifts();
const update_success = await shift_store.updateShifts(); const update_success = await shift_store.updateShifts();
if (update_success) { if (create_success || update_success){
await timesheet_store.getTimesheetsByEmployeeEmail(auth_store.user?.email ?? '') await timesheet_store.getTimesheetsByEmployeeEmail(auth_store.user?.email ?? '');
}
} }
timesheet_store.is_loading = false; timesheet_store.is_loading = false;
} }

View File

@ -9,6 +9,8 @@ export const SHIFT_TYPES: ShiftType[] = [
export type ShiftType = 'REGULAR' | 'EVENING' | 'EMERGENCY' | 'HOLIDAY' | 'VACATION' | 'SICK'; export type ShiftType = 'REGULAR' | 'EVENING' | 'EMERGENCY' | 'HOLIDAY' | 'VACATION' | 'SICK';
export type ShiftErrorCode = 'SHIFT_OVERLAP' | 'MISSING_START_TIME' | 'MISSING_END_TIME' | 'COMMENT_LENGTH_EXCEEDED' | 'APPROVAL_LOCK' | 'INVALID_DATE' | 'INVALID TYPE' | 'INVALID_TIMESHEET';
export type ShiftLegendItem = { export type ShiftLegendItem = {
type: ShiftType; type: ShiftType;
color: string; color: string;
@ -17,7 +19,7 @@ export type ShiftLegendItem = {
}; };
export class Shift { export class Shift {
shift_id: number; id: number;
timesheet_id: number; timesheet_id: number;
date: string; //YYYY-MM-DD date: string; //YYYY-MM-DD
type: ShiftType; type: ShiftType;
@ -28,7 +30,7 @@ export class Shift {
is_remote: boolean; is_remote: boolean;
constructor() { constructor() {
this.shift_id = -1; this.id = -1;
this.timesheet_id = -1; this.timesheet_id = -1;
this.date = ''; this.date = '';
this.type = 'REGULAR'; this.type = 'REGULAR';
@ -40,7 +42,21 @@ export class Shift {
} }
} }
export interface NewShift { export interface ShiftAPIResponse {
timesheet_id: number; ok: boolean;
shifts: Shift[]; data?: {
shift: Shift;
overtime: unknown;
}
error?: ShiftAPIError;
}
export interface ShiftAPIError {
error_code: ShiftErrorCode;
conflicts:
{
date: string;
start_time: string;
end_time: string;
}
} }

View File

@ -1,6 +1,5 @@
/* eslint-disable */
import { api } from "src/boot/axios"; import { api } from "src/boot/axios";
import type { Shift } from "src/modules/timesheets/models/shift.models"; import type { Shift, ShiftAPIResponse } from "src/modules/timesheets/models/shift.models";
export const ShiftService = { export const ShiftService = {
deleteShiftById: async (shift_id: number) => { deleteShiftById: async (shift_id: number) => {
@ -8,17 +7,15 @@ export const ShiftService = {
return response.data; return response.data;
}, },
createNewShifts: async (new_shifts: Shift[]) => { createNewShifts: async (new_shifts: Shift[]):Promise<ShiftAPIResponse[]> => {
// const response = await api.post(`/shift/`, { dtos: new_shifts }); const response = await api.post(`/shift/create`, new_shifts);
// return response; return response.data;
console.log('create shift payload: ', new_shifts);
return {status: 200};
}, },
updateShifts: async (existing_shifts: Shift[]) => { updateShifts: async (existing_shifts: Shift[]) => {
// const response = await api.patch(`/shift/`, { dtos: existing_shifts }); console.log('sent shifts: ', existing_shifts)
// return response; const response = await api.patch(`/shift/update`, existing_shifts);
console.log('update shift payload: ', existing_shifts); console.log('API response to existing shifts: ', response.data);
return {status: 200}; return response;
} }
}; };

View File

@ -19,8 +19,8 @@ export const timesheetService = {
return response.data; return response.data;
}, },
getTimesheetsByPayPeriodAndEmployeeEmail: async (employee_email: string, year: number, period_number: number): Promise<TimesheetResponse> => { getTimesheetsByPayPeriod: async (year: number, period_number: number): Promise<TimesheetResponse> => {
const response = await api.get('timesheets', { params: { employee_email, year, period_number } }); const response = await api.get('timesheets', { params: { year, period_number } });
return response.data; return response.data;
}, },
}; };

View File

@ -2,7 +2,16 @@
setup setup
lang="ts" lang="ts"
> >
import { Notify } from 'quasar'; import { ref } from 'vue';
import { Notify } from 'quasar';
const LOREM_IPSUM = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et \
dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip \
ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu \
fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia \
deserunt mollit anim id est laborum."
const slide = ref<string>('welcome');
const clickNotify = () => { const clickNotify = () => {
Notify.create({ Notify.create({
@ -10,38 +19,84 @@ import { Notify } from 'quasar';
color: 'info' color: 'info'
}) })
} }
</script> </script>
<template> <template>
<q-page <q-page
padding padding
class="q-pa-md row items-center justify-center" class="q-pa-md row justify-center"
> >
<q-card class="shadow-2 col-9 dark-font"> <q-card flat class="column col-9 transparent ">
<q-img src="src/assets/line-truck-1.jpg"> <div class="col-1"></div>
<q-carousel
v-model="slide"
transition-prev="jump-right"
transition-next="jump-left"
swipeable
animated
control-color="accent"
navigation-icon="radio_button_unchecked"
navigation
class="col-5 bg-dark rounded-15 shadow-2"
>
<q-carousel-slide
name="welcome"
class="column no-wrap flex-center q-pa-none q-pb-xl"
>
<q-img src="src/assets/line-truck-1.jpg" class="full-height">
<div class="absolute-bottom text-h5"> <div class="absolute-bottom text-h5">
Welcome to App Targo, ! Welcome to App Targo!
</div> </div>
</q-img> </q-img>
<div class="q-mt-md text-center">
<q-card-section class="text-center"> {{ LOREM_IPSUM }}
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et </div>
dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip </q-carousel-slide>
ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu <q-carousel-slide
fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia name="tv"
deserunt mollit anim id est laborum. class="column no-wrap flex-center q-pa-none q-pb-xl"
</q-card-section> >
<q-icon
<q-separator /> name="live_tv"
size="56px"
<q-card-actions align="center"> />
<div class="q-mt-md text-center">
{{ LOREM_IPSUM }}
</div>
</q-carousel-slide>
<q-carousel-slide
name="layers"
class="column no-wrap flex-center q-pa-none q-pb-xl"
>
<q-icon
name="layers"
size="56px"
/>
<div class="q-mt-md text-center">
{{ LOREM_IPSUM }}
</div>
</q-carousel-slide>
<q-carousel-slide
name="map"
class="column no-wrap flex-center q-pa-none q-pb-xl"
>
<q-icon
name="terrain"
size="56px"
/>
<div class="q-mt-md text-center">
{{ LOREM_IPSUM }}
</div>
</q-carousel-slide>
</q-carousel>
<div class="col column flex-center">
<q-btn <q-btn
color="primary" push
color="accent"
label="Click Me" label="Click Me"
@click="clickNotify" @click="clickNotify"
/> />
</q-card-actions> </div>
</q-card> </q-card>
</q-page> </q-page>
</template> </template>

View File

@ -1,12 +1,13 @@
import { ref } from "vue"; import { ref } from "vue";
import { Notify } from "quasar";
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { ShiftService } from "src/modules/timesheets/services/shift-service"; import { ShiftService } from "src/modules/timesheets/services/shift-service";
import { useTimesheetStore } from "src/stores/timesheet-store"; import { useTimesheetStore } from "src/stores/timesheet-store";
import { Notify } from "quasar"; import type { ShiftAPIError } from "src/modules/timesheets/models/shift.models";
export const useShiftStore = defineStore('shift_store', () => { export const useShiftStore = defineStore('shift_store', () => {
const timesheet_store = useTimesheetStore(); const timesheet_store = useTimesheetStore();
const shift_error = ref(); const shift_errors = ref<ShiftAPIError[]>([]);
const deleteShiftById = async (shift_id: number): Promise<boolean> => { const deleteShiftById = async (shift_id: number): Promise<boolean> => {
try { try {
@ -19,20 +20,25 @@ export const useShiftStore = defineStore('shift_store', () => {
}; };
const createNewShifts = async (): Promise<boolean> => { const createNewShifts = async (): Promise<boolean> => {
if (timesheet_store.timesheets === undefined) return false; if (timesheet_store.timesheets === undefined) {
console.log('no changes in existing shifts detected');
return false;
}
try { try {
const new_shifts = timesheet_store.timesheets.flatMap(week => week.days).flatMap(day => day.shifts).filter(shift => shift.shift_id < 0); const new_shifts = timesheet_store.timesheets.flatMap(week => week.days).flatMap(day => day.shifts).filter(shift => shift.id < 0);
if (new_shifts?.length > 0) { if (new_shifts?.length > 0) {
const response = await ShiftService.createNewShifts(new_shifts); const response = await ShiftService.createNewShifts(new_shifts);
if (response.status <= 200) { if (response.every(res => res.ok)) {
return true; return true;
} }
else {
response.forEach(res => {
shift_errors.value.push(res.error!);
});
}
} }
console.log('No new shifts to save');
Notify.create('no new shifts to save')
return false; return false;
} catch (error) { } catch (error) {
console.error('Error creating new shifts: ', error); console.error('Error creating new shifts: ', error);
@ -44,11 +50,12 @@ export const useShiftStore = defineStore('shift_store', () => {
if (timesheet_store.timesheets === undefined) return false; if (timesheet_store.timesheets === undefined) return false;
try { try {
const existing_shifts = timesheet_store.timesheets.flatMap(week => week.days).flatMap(day => day.shifts).filter(shift => shift.shift_id > 0); const existing_shifts = timesheet_store.timesheets.flatMap(week => week.days).flatMap(day => day.shifts).filter(shift => shift.id > 0);
if (existing_shifts?.length > 0) { if (existing_shifts?.length > 0) {
const response = await ShiftService.updateShifts(existing_shifts); const response = await ShiftService.updateShifts(existing_shifts);
if (response.status <= 200) {
if (response.status < 400) {
return true; return true;
} }
} }
@ -63,7 +70,7 @@ export const useShiftStore = defineStore('shift_store', () => {
} }
return { return {
shift_error, shift_errors,
deleteShiftById, deleteShiftById,
createNewShifts, createNewShifts,
updateShifts, updateShifts,

View File

@ -7,6 +7,7 @@ import type { TimesheetOverview } from "src/modules/timesheet-approval/models/ti
import type { PayPeriod } from 'src/modules/shared/models/pay-period.models'; import type { PayPeriod } from 'src/modules/shared/models/pay-period.models';
import type { Timesheet } from 'src/modules/timesheets/models/timesheet.models'; import type { Timesheet } from 'src/modules/timesheets/models/timesheet.models';
import type { TimesheetApprovalCSVReportFilters } from 'src/modules/timesheet-approval/models/timesheet-approval-csv-report.models'; import type { TimesheetApprovalCSVReportFilters } from 'src/modules/timesheet-approval/models/timesheet-approval-csv-report.models';
import { unwrapAndClone } from 'src/utils/unwrap-and-clone';
export const useTimesheetStore = defineStore('timesheet', () => { export const useTimesheetStore = defineStore('timesheet', () => {
@ -15,7 +16,8 @@ export const useTimesheetStore = defineStore('timesheet', () => {
const pay_period = ref<PayPeriod>(); const pay_period = ref<PayPeriod>();
const pay_period_overviews = ref<TimesheetOverview[]>([]); const pay_period_overviews = ref<TimesheetOverview[]>([]);
const current_pay_period_overview = ref<TimesheetOverview>(); const current_pay_period_overview = ref<TimesheetOverview>();
const timesheets = ref<Timesheet[]>(); const timesheets = ref<Timesheet[]>([]);
const initial_timesheets = ref<Timesheet[]>([]);
const pay_period_report = ref(); const pay_period_report = ref();
const getPayPeriodByDateOrYearAndNumber = async (date_or_year: string | number, period_number?: number): Promise<boolean> => { const getPayPeriodByDateOrYearAndNumber = async (date_or_year: string | number, period_number?: number): Promise<boolean> => {
@ -58,13 +60,17 @@ export const useTimesheetStore = defineStore('timesheet', () => {
} }
}; };
const getTimesheetsByEmployeeEmail = async (employee_email: string) => { const getTimesheetsByEmployeeEmail = async (employee_email?: string) => {
is_loading.value = true; is_loading.value = true;
if (pay_period.value === undefined) return; if (pay_period.value === undefined) return;
try { try {
const response = await timesheetService.getTimesheetsByPayPeriodAndEmployeeEmail(employee_email, pay_period.value.pay_year, pay_period.value.pay_period_no); if (employee_email) {
console.log('email: ', employee_email);
}
const response = await timesheetService.getTimesheetsByPayPeriod(pay_period.value.pay_year, pay_period.value.pay_period_no);
timesheets.value = response.timesheets; timesheets.value = response.timesheets;
initial_timesheets.value = unwrapAndClone(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);
@ -97,6 +103,7 @@ export const useTimesheetStore = defineStore('timesheet', () => {
pay_period_overviews, pay_period_overviews,
current_pay_period_overview, current_pay_period_overview,
timesheets, timesheets,
initial_timesheets,
getPayPeriodByDateOrYearAndNumber, getPayPeriodByDateOrYearAndNumber,
getTimesheetOverviewsByPayPeriod, getTimesheetOverviewsByPayPeriod,
getTimesheetsByEmployeeEmail, getTimesheetsByEmployeeEmail,