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;
} }
@ -41,4 +41,16 @@ body.body--dark {
.frosted-glass { .frosted-glass {
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,22 +1,36 @@
<script lang="ts" setup> <script
import { useUiStore } from 'src/stores/ui-store'; lang="ts"
import HeaderBarNotification from './main-layout-header-bar-notification.vue'; setup
>
import { useUiStore } from 'src/stores/ui-store';
import HeaderBarNotification from './main-layout-header-bar-notification.vue';
const uiStore = useUiStore(); const uiStore = useUiStore();
</script> </script>
<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
</q-btn> dense
</q-toolbar-title> color="white"
<q-item class="q-pa-none"> @click="uiStore.toggleRightDrawer"
<HeaderBarNotification /> class="q-px-none"
</q-item> >
</q-toolbar> <q-icon name="menu" size="lg" class="q-mr-lg"/>
</q-header> <q-img
src="src/assets/logo-targo-white.svg"
fit="contain"
width="150px"
height="30px"
/>
</q-btn>
</q-toolbar-title>
<q-item class="q-pa-none">
<HeaderBarNotification />
</q-item>
</q-toolbar>
</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,18 +1,24 @@
<script setup lang="ts"> <script
const { userFirstName = '', userLastName = '' } = defineProps<{ setup
lang="ts"
>
defineProps<{
userFirstName: string; userFirstName: string;
userLastName: string; userLastName: string;
}>(); }>();
</script> </script>
<template> <template>
<q-img <q-img
src="src/assets/profile_header_default.png" src="src/assets/profile_header_default.png"
height="15vh" height="15vh"
:width="$q.screen.lt.md ? '80vw' : '40vw'" :width="$q.screen.lt.md ? '80vw' : '40vw'"
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> <template #label>
<span class="text-weight-bold text-accent text-uppercase text-caption">
{{ $t('timesheet.expense.amount') }}
</span>
</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> <template #label>
<span class="text-weight-bold text-accent text-uppercase text-caption">
{{ $t('timesheet.expense.mileage') }}
</span>
</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 --> <q-btn
<div class="col-auto column"> v-if="expenses_store.mode === 'update'"
<q-btn flat
v-if="expenses_store.mode === 'update'" dense
push class="col-auto q-ml-sm"
dense icon="clear"
size="sm" color="negative"
class="col q-ml-sm" :label="$q.screen.gt.sm ? $t('shared.label.cancel') : ''"
icon="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')) : ''" class="q-px-sm q-mb-sm q-mx-lg"
size="sm" type="submit"
class="col q-mx-xs q-my-sm q-pr-sm" />
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') }}
> </q-item-label>
{{ $t('timesheet.expense.title') }}
</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
@ -35,7 +37,7 @@
<ExpenseDialogHeader /> <ExpenseDialogHeader />
<ExpenseDialogList /> <ExpenseDialogList />
<transition <transition
appear appear
enter-active-class="animated fadeInDown faster" enter-active-class="animated fadeInDown faster"
@ -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,77 +44,118 @@
: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 <div
class="col row bg-dark" v-if="ui_store.is_mobile_mode"
style="border-radius: 10px 0 0 10px;" class="col column full-width"
> >
<!-- Dates column --> <q-card class="rounded-5 q-my-md" :class="getDayApproval(day) ? 'bg-accent' : 'bg-dark'">
<div
class="col-auto column flex-center bg-primary rounded-10 text-center q-ma-sm self-center" <q-card-section
:class="$q.screen.lt.md ? '' : ''" class="text-white text-weight-bolder text-uppercase text-h6 q-py-xs"
:style="date_box_size" :class="getDayApproval(day) ? 'bg-dark' : 'bg-accent'"
> style="line-height: 1em;"
<span
v-if="!dense"
class="col-auto text-uppercase text-white"
:style="'font-size: ' + weekday_font_size"
> >
{{ $d(date.extractDate(day.date, 'YYYY-MM-DD'), { <span> {{ $d(extractDate(day.date, 'YYYY-MM-DD'), {
weekday: $q.screen.lt.md ? 'short' : weekday: 'long', day: 'numeric', month:
'long' 'long'
}) }) }}</span>
}} </q-card-section>
</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 --> <q-card-section
<div class="col column"> v-if="day.shifts.filter(shift => shift.id !== 0).length > 0"
<ShiftListRow class="q-pa-none transparent"
v-for="shift, shift_index in day.shifts" >
:key="shift_index" <ShiftListDay
v-model:shift="day.shifts[shift_index]!" outlined
:dense="dense" :approved="getDayApproval(day)"
@request-delete="deleteCurrentShift(shift)" :day="day"
/> @delete-unsaved-shift="deleteUnsavedShift(timesheet_index, day_index)"
</div> />
</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>
<div class="col-auto self-stretch"> <div
<q-btn v-else
unelevated class="col row full-width"
icon="more_time" :class="getDayApproval(day) ? 'rounded-10 bg-accent' : ''"
:size="$q.screen.lt.md ? 'md' : 'lg'" >
color="primary" <div
text-color="white" class="col row bg-dark"
class="full-height" :class="getDayApproval(day) ? 'bg-transparent' : ''"
:class="$q.screen.lt.md ? 'q-px-xs' : ''" style="border-radius: 10px 0 0 10px;"
style="border-radius: 0 10px 10px 0;" >
@click="addNewShift(day.shifts, day.date, timesheet.timesheet_id)" <!-- Date block -->
/> <ShiftListDateWidget
:display-date="day.date"
:approved="getDayApproval(day)"
class="col-auto"
/>
<!-- List of shifts -->
<ShiftListDay
:day="day"
class="col"
@delete-unsaved-shift="deleteUnsavedShift(timesheet_index, day_index)"
/>
</div>
<div class="col-auto self-stretch">
<q-icon
v-if="getDayApproval(day)"
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>
</div> </div>

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();
@ -76,12 +77,12 @@ import { useShiftApi } from 'src/modules/timesheets/composables/use-shift-api';
<q-space /> <q-space />
<!-- save timesheet changes button --> <!-- save timesheet changes button -->
<q-btn <q-btn
v-if="$q.screen.gt.sm" v-if="$q.screen.gt.sm"
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(); const create_success = await shift_store.createNewShifts();
const update_success = await shift_store.updateShifts();
if (create_success) { if (create_success || update_success){
const update_success = await shift_store.updateShifts(); await timesheet_store.getTimesheetsByEmployeeEmail(auth_store.user?.email ?? '');
if (update_success) {
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>
<div class="absolute-bottom text-h5"> <q-carousel
Welcome to App Targo, ! v-model="slide"
</div> transition-prev="jump-right"
</q-img> transition-next="jump-left"
swipeable
<q-card-section class="text-center"> animated
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et control-color="accent"
dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip navigation-icon="radio_button_unchecked"
ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu navigation
fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia class="col-5 bg-dark rounded-15 shadow-2"
deserunt mollit anim id est laborum. >
</q-card-section> <q-carousel-slide
name="welcome"
<q-separator /> class="column no-wrap flex-center q-pa-none q-pb-xl"
>
<q-card-actions align="center"> <q-img src="src/assets/line-truck-1.jpg" class="full-height">
<div class="absolute-bottom text-h5">
Welcome to App Targo!
</div>
</q-img>
<div class="q-mt-md text-center">
{{ LOREM_IPSUM }}
</div>
</q-carousel-slide>
<q-carousel-slide
name="tv"
class="column no-wrap flex-center q-pa-none q-pb-xl"
>
<q-icon
name="live_tv"
size="56px"
/>
<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,