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 {
--q-secondary: #2b2f34;
--q-secondary: #151520;
color: $grey-2;
}
@ -42,3 +42,15 @@ body.body--dark {
background-color: #FFFA !important;
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.
// Tip: Use the "Theme Builder" on Quasar's documentation website.
$primary : #019547;
$primary : #30303A;
$secondary : #DAE0E7;
$accent : #83f29f7d;
$accent : #0c9a3b;
$dark-shadow-color : #00220f;
@ -30,9 +30,10 @@ $input-autofill-color : #AAD5C4;
$field-dense-label-top : 5px !default;
$field-dense-label-font-size : 16px !default;
$button-shadow : 0 0 0 transparent;
$dark : #42444b;
$dark-page : #343434;
$dark : #40404C;
$dark-page : #343444;
$positive : #21ba45;
$negative : #e6364b;

View File

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

View File

@ -151,12 +151,12 @@ export default {
REMOTE: "Télétravail",
},
errors: {
not_found:"Aucun quart trouvé",
overlap:"Il y a un chevauchement entre deux ou plusieurs quarts",
invalid:"Entrée du quart invalide",
unknown:"Erreur inconnue",
comment_required:"un commentaire est requis",
comment_too_long:"votre commentaire est trop long",
not_found: "Aucun quart trouvé",
SHIFT_OVERLAP: "Il y a un chevauchement entre deux ou plusieurs quarts",
invalid: "Entrée du quart invalide",
unknown: "Erreur inconnue",
comment_required: "un commentaire est requis",
comment_too_long: "votre commentaire est trop long",
},
fields: {
start:"Début (HH:mm)",
@ -197,7 +197,7 @@ export default {
PER_DIEM:"Per diem",
EXPENSES:"dépense",
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 HeaderBarNotification from './main-layout-header-bar-notification.vue';
@ -7,10 +10,22 @@
<template>
<q-header elevated>
<q-toolbar>
<q-toolbar class="q-px-sm">
<q-toolbar-title>
<q-btn flat dense color="white" icon="menu" @click="uiStore.toggleRightDrawer">
<q-img src="src/assets/logo-targo-white.svg" fit="contain" width="150px" height="30px"/>
<q-btn
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-toolbar-title>
<q-item class="q-pa-none">
@ -19,4 +34,3 @@
</q-toolbar>
</q-header>
</template>

View File

@ -32,14 +32,14 @@
<template>
<q-drawer
v-model="ui_store.is_left_drawer_open"
persistent
:persistent="!ui_store.is_mobile_mode"
mini-to-overlay
elevated
side="left"
:mini="is_mini"
@mouseenter="is_mini = false"
@mouseleave="is_mini = true"
class="bg-dark"
class="bg-dark z-max"
>
<q-scroll-area class="fit">
<q-list>
@ -53,7 +53,8 @@
<q-item-section avatar>
<q-icon
name="home"
color="primary"
color="accent"
size="lg"
/>
</q-item-section>
<q-item-section>
@ -72,7 +73,8 @@
<q-item-section avatar>
<q-icon
name="event_available"
color="primary"
color="accent"
size="lg"
/>
</q-item-section>
<q-item-section>
@ -92,7 +94,8 @@
<q-item-section avatar>
<q-icon
name="view_list"
color="primary"
color="accent"
size="lg"
/>
</q-item-section>
<q-item-section>
@ -112,7 +115,8 @@
<q-item-section avatar>
<q-icon
name="punch_clock"
color="primary"
color="accent"
size="lg"
/>
</q-item-section>
<q-item-section>
@ -131,7 +135,8 @@
<q-item-section avatar>
<q-icon
name="account_box"
color="primary"
color="accent"
size="lg"
/>
</q-item-section>
<q-item-section>
@ -148,7 +153,8 @@
<q-item-section avatar>
<q-icon
name="contact_support"
color="primary"
color="accent"
size="lg"
/>
</q-item-section>
<q-item-section>
@ -167,7 +173,8 @@
<q-item-section avatar>
<q-icon
name="exit_to_app"
color="primary"
color="accent"
size="lg"
/>
</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 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 { useAuthStore } from 'src/stores/auth-store';
const auth_store = useAuthStore();
const PanelNames = {
PERSONAL_INFO: 'personal_info',
@ -24,8 +27,8 @@
class="rounded-5 bg-transparent q-pa-none"
>
<MenuTemplate
:first-name="employee_profile.first_name"
:last-name="employee_profile.last_name"
:first-name="employee_profile.first_name === '' ? auth_store.user?.first_name ?? '' : employee_profile.first_name"
:last-name="employee_profile.last_name === '' ? auth_store.user?.last_name ?? '' : employee_profile.last_name"
:initial-menu="PanelNames.PERSONAL_INFO"
>
<template #tabs>

View File

@ -1,5 +1,8 @@
<script setup lang="ts">
const { userFirstName = '', userLastName = '' } = defineProps<{
<script
setup
lang="ts"
>
defineProps<{
userFirstName: string;
userLastName: string;
}>();
@ -13,6 +16,9 @@
class="rounded-5 q-mb-md shadow-2 col-auto"
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>
</template>

View File

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

View File

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

View File

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

View File

@ -21,13 +21,13 @@
v-if="startDate.length > 0"
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) }}
</div>
<div class="text-body2 q-mx-md text-weight-medium">
{{ $t('shared.misc.to') }}
</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) }}
</div>
</div>

View File

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

View File

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

View File

@ -2,43 +2,91 @@
setup
lang="ts"
>
/* eslint-disable */
import { computed } from 'vue';
import { useExpensesStore } from 'src/stores/expense-store';
import { useTimesheetStore } from 'src/stores/timesheet-store';
const timesheet_store = useTimesheetStore();
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>
<template>
<q-item class="row justify-between items-center q-pa-none">
<q-item-label
header
class="text-h6 col q-pa-none"
>
<div class="column items-center q-pa-none">
<div class="col row full-width">
<q-item-label class="col text-h6 text-weight-bolder text-uppercase q-py-sm q-px-md">
{{ $t('timesheet.expense.title') }}
</q-item-label>
<!-- <q-item-section
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-space />
<q-item-section
no-wrap
class="col-auto items-center"
>
<q-badge
outline
class="q-py-xs q-px-md"
color="primary"
:label="$t('timesheet.expense.total_mileage') + ': ' + expense_store.pay_period_expenses?.total_mileage.toFixed(1) + ' km'"
<q-btn
square
icon="clear"
color="negative"
class="col-auto"
style="border-radius: 0 0 0 5px;"
@click="expense_store.close"
/>
</q-item-section> -->
</q-item>
</div>
<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>

View File

@ -4,13 +4,13 @@
>
import { computed, inject, ref } from 'vue';
import { unwrapAndClone } from 'src/utils/unwrap-and-clone';
import { deepEqual } from 'src/utils/deep-equal';
import { useExpensesApi } from 'src/modules/timesheets/composables/use-expense-api';
import { useExpensesStore } from 'src/stores/expense-store';
import { getExpenseIcon } from 'src/modules/timesheets/utils/expense.util';
import { useAuthStore } from 'src/stores/auth-store';
import { CAN_APPROVE_PAY_PERIODS } from 'src/modules/shared/models/user.models';
import { Expense } from 'src/modules/timesheets/models/expense.models';
import { deepEqual } from 'src/utils/deep-equal';
const { expense, horizontal = false } = defineProps<{
expense: Expense;
@ -25,18 +25,11 @@
const employee_email = inject<string>('employeeEmail') ?? '';
const refresh_key = ref(1);
const background_class = computed(() => deepEqual(expense, expenses_store.current_expense) ? 'bg-accent' : '');
const approved_class = computed(() => horizontal ? ' q-mx-xs q-pa-xs cursor-pointer' : '')
const expense_item_style = computed(() => is_approved.value ? 'border: solid 2px var(--q-primary);' : 'border: solid 2px grey;');
const background_class = computed(() => deepEqual(expense, expenses_store.current_expense) ? '' : '');
const background_style = computed(() => deepEqual(expense, expenses_store.current_expense) ? 'border: 3px solid var(--q-accent);' : '');
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 setExpenseToUpdate = () => {
expenses_store.mode = 'update';
// if (expense.is_approved) return;
expenses_store.current_expense = expense;
expenses_store.initial_expense = unwrapAndClone(expense);
};
const requestExpenseDeletion = async () => {
// expenses_store.mode = 'delete';
expenses_store.initial_expense = expense;
@ -52,13 +45,15 @@
}
const onUpdateClicked = () => {
if (expenses_store.mode === 'update') {
if (deepEqual(expense, expenses_store.current_expense)){
expenses_store.mode = 'create';
expenses_store.current_expense = new Expense;
expenses_store.initial_expense = new Expense;
return;
}
setExpenseToUpdate();
expenses_store.mode = 'update';
expenses_store.current_expense = expense;
expenses_store.initial_expense = unwrapAndClone(expense);
}
</script>
@ -70,28 +65,16 @@
<q-item
:key="refresh_key"
: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"
:style="expense_item_style"
:style="background_style"
@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 -->
<q-item-section avatar>
<q-icon
: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"
>
<q-badge
@ -102,14 +85,14 @@
<q-icon
name="shield"
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-icon>
</q-item-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'">
<template v-if="typeof expense.mileage === 'number'">
{{ expense.mileage?.toFixed(1) }} km
@ -126,7 +109,8 @@
<q-item-label
caption
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' }) }}
</q-item-label>
@ -135,28 +119,32 @@
<q-space v-if="horizontal" />
<!-- attachment file icon -->
<q-item-section side>
<q-item-section avatar>
<q-btn
push
dense
size="md"
color="primary"
class="q-mx-lg"
:color="expense.is_approved ? 'white' : 'accent'"
:text-color="expense.is_approved ? 'accent' : 'white'"
class="col-auto q-mx-sm q-px-sm q-pb-sm"
icon="attach_file"
/>
</q-item-section>
<q-item-label class="col text-weight-light text-caption">
<span>attachment_goes_here.jpg</span>
</q-item-label>
<!-- comment section -->
<q-item-section
v-if="!horizontal"
top
>
<q-item-label lines="1">
<q-item-label lines="1" class="text-weight-medium text-uppercase">
{{ $t('timesheet.expense.employee_comment') }}
</q-item-label>
<q-item-label
caption
lines="1"
:class="approved_class"
>
{{ expense.comment }}
</q-item-label>
@ -164,10 +152,10 @@
<!-- supervisor comment section -->
<q-item-section
v-if="expense.supervisor_comment && !horizontal"
v-if="is_authorized_to_approve"
top
>
<q-item-label lines="1">
<q-item-label lines="1" class="text-weight-medium text-uppercase">
{{ $t('timesheet.expense.supervisor_comment') }}
</q-item-label>
<q-item-label
@ -181,22 +169,23 @@
<q-item-section :side="$q.screen.gt.sm">
<q-btn
push
dense
color="primary"
flat
color="accent"
icon="edit"
class="z-top"
size="lg"
class="q-pa-none z-top"
:class="expense.is_approved ? 'invisible no-pointer' : ''"
@click.stop="onUpdateClicked"
/>
</q-item-section>
<q-item-section :side="$q.screen.gt.sm">
<q-btn
push
dense
color="negative"
icon="close"
class="z-top"
flat
:color="expense.is_approved ? 'white' : 'negative'"
:icon="expense.is_approved ? 'verified' : 'close'"
size="lg"
class="q-pa-none z-top"
@click.stop="requestExpenseDeletion"
/>
</q-item-section>

View File

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

View File

@ -14,16 +14,18 @@
<q-dialog
v-model="expense_store.is_open"
persistent
transition-show="jump-down"
transition-hide="jump-down"
>
<q-card
class="q-pa-md"
class="q-pa-none rounded-10 shadow-10 bg-secondary"
style=" min-width: 70vw;"
>
<q-inner-loading :showing="expense_store.is_loading">
<q-spinner size="32px" />
</q-inner-loading>
<q-card-section>
<q-card-section class="q-pa-none">
<!-- <q-banner
v-if="expenses_error"
dense
@ -50,22 +52,7 @@
size="lg"
/>
</transition>
<q-separator spaced />
</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-dialog>
</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"
>
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 { 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 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) => {
ui_store.focus_next_component = true;
@ -33,13 +23,18 @@
day_shifts.push(new_shift);
};
const deleteCurrentShift = async (shift: Shift) => {
console.log('shift to delete: ', shift);
if (shift.shift_id < 0) {
shift.shift_id = 0;
return;
const deleteUnsavedShift = (timesheet_index: number, day_index: number) => {
if (timesheet_store.timesheets !== undefined) {
const day = timesheet_store.timesheets[timesheet_index]!.days[day_index]!;
const shifts_without_deleted_shift = day.shifts.filter(shift => shift.id !== 0);
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>
@ -49,74 +44,114 @@
:style="$q.screen.lt.md ? 'width: 90vw !important;' : ''"
>
<div
v-for="timesheet in timesheet_store.timesheets"
v-for="timesheet, timesheet_index in timesheet_store.timesheets"
:key="timesheet.timesheet_id"
class="col column"
>
<div
v-for="day in timesheet.days"
v-for="day, day_index in timesheet.days"
: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
class="col row bg-dark"
:class="getDayApproval(day) ? 'bg-transparent' : ''"
style="border-radius: 10px 0 0 10px;"
>
<!-- Dates column -->
<div
class="col-auto column flex-center bg-primary rounded-10 text-center q-ma-sm self-center"
:class="$q.screen.lt.md ? '' : ''"
:style="date_box_size"
>
<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'), {
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)"
<!-- 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>
<div class="col-auto self-stretch">
<q-icon
v-if="getDayApproval(day)"
name="verified"
color="white"
size="xl"
class="full-height"
/>
<q-btn
unelevated
v-else
:dense="!ui_store.is_mobile_mode"
icon="more_time"
:size="$q.screen.lt.md ? 'md' : 'lg'"
color="primary"
size="lg"
color="accent"
text-color="white"
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;"
@click="addNewShift(day.shifts, day.date, timesheet.timesheet_id)"
/>
@ -124,4 +159,5 @@
</div>
</div>
</div>
</div>
</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 ExpenseDialog from 'src/modules/timesheets/components/expense-dialog.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 { useTimesheetApi } from 'src/modules/timesheets/composables/use-timesheet-api';
import { useExpensesStore } from 'src/stores/expense-store';
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 shift_api = useShiftApi();
@ -81,7 +82,7 @@ import { useShiftApi } from 'src/modules/timesheets/composables/use-shift-api';
push
rounded
:disable="timesheet_store.is_loading"
color="primary"
color="accent"
icon="upload"
:label="$t('shared.label.save')"
class="q-mr-md"
@ -93,7 +94,7 @@ import { useShiftApi } from 'src/modules/timesheets/composables/use-shift-api';
v-if="$q.screen.gt.sm"
push
rounded
color="primary"
color="accent"
icon="receipt_long"
:label="$t('timesheet.expense.open_btn')"
@click="open"
@ -101,6 +102,10 @@ import { useShiftApi } from 'src/modules/timesheets/composables/use-shift-api';
</q-card-section>
<q-card-section class="q-pa-none">
<TimesheetErrorWidget />
</q-card-section>
<ShiftList :dense="dense" />
</q-card>
<ExpenseDialog />

View File

@ -20,15 +20,14 @@ export const useShiftApi = () => {
const saveShiftChanges = async () => {
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();
if (update_success) {
await timesheet_store.getTimesheetsByEmployeeEmail(auth_store.user?.email ?? '')
}
if (create_success || update_success){
await timesheet_store.getTimesheetsByEmployeeEmail(auth_store.user?.email ?? '');
}
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 ShiftErrorCode = 'SHIFT_OVERLAP' | 'MISSING_START_TIME' | 'MISSING_END_TIME' | 'COMMENT_LENGTH_EXCEEDED' | 'APPROVAL_LOCK' | 'INVALID_DATE' | 'INVALID TYPE' | 'INVALID_TIMESHEET';
export type ShiftLegendItem = {
type: ShiftType;
color: string;
@ -17,7 +19,7 @@ export type ShiftLegendItem = {
};
export class Shift {
shift_id: number;
id: number;
timesheet_id: number;
date: string; //YYYY-MM-DD
type: ShiftType;
@ -28,7 +30,7 @@ export class Shift {
is_remote: boolean;
constructor() {
this.shift_id = -1;
this.id = -1;
this.timesheet_id = -1;
this.date = '';
this.type = 'REGULAR';
@ -40,7 +42,21 @@ export class Shift {
}
}
export interface NewShift {
timesheet_id: number;
shifts: Shift[];
export interface ShiftAPIResponse {
ok: boolean;
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 type { Shift } from "src/modules/timesheets/models/shift.models";
import type { Shift, ShiftAPIResponse } from "src/modules/timesheets/models/shift.models";
export const ShiftService = {
deleteShiftById: async (shift_id: number) => {
@ -8,17 +7,15 @@ export const ShiftService = {
return response.data;
},
createNewShifts: async (new_shifts: Shift[]) => {
// const response = await api.post(`/shift/`, { dtos: new_shifts });
// return response;
console.log('create shift payload: ', new_shifts);
return {status: 200};
createNewShifts: async (new_shifts: Shift[]):Promise<ShiftAPIResponse[]> => {
const response = await api.post(`/shift/create`, new_shifts);
return response.data;
},
updateShifts: async (existing_shifts: Shift[]) => {
// const response = await api.patch(`/shift/`, { dtos: existing_shifts });
// return response;
console.log('update shift payload: ', existing_shifts);
return {status: 200};
console.log('sent shifts: ', existing_shifts)
const response = await api.patch(`/shift/update`, existing_shifts);
console.log('API response to existing shifts: ', response.data);
return response;
}
};

View File

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

View File

@ -2,7 +2,16 @@
setup
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 = () => {
Notify.create({
@ -10,38 +19,84 @@ import { Notify } from 'quasar';
color: 'info'
})
}
</script>
<template>
<q-page
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-img src="src/assets/line-truck-1.jpg">
<q-card flat class="column col-9 transparent ">
<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">
Welcome to App Targo, !
Welcome to App Targo!
</div>
</q-img>
<q-card-section class="text-center">
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.
</q-card-section>
<q-separator />
<q-card-actions align="center">
<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
color="primary"
push
color="accent"
label="Click Me"
@click="clickNotify"
/>
</q-card-actions>
</div>
</q-card>
</q-page>
</template>

View File

@ -1,12 +1,13 @@
import { ref } from "vue";
import { Notify } from "quasar";
import { defineStore } from "pinia";
import { ShiftService } from "src/modules/timesheets/services/shift-service";
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', () => {
const timesheet_store = useTimesheetStore();
const shift_error = ref();
const shift_errors = ref<ShiftAPIError[]>([]);
const deleteShiftById = async (shift_id: number): Promise<boolean> => {
try {
@ -19,20 +20,25 @@ export const useShiftStore = defineStore('shift_store', () => {
};
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 {
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) {
const response = await ShiftService.createNewShifts(new_shifts);
if (response.status <= 200) {
if (response.every(res => res.ok)) {
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;
} catch (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;
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) {
const response = await ShiftService.updateShifts(existing_shifts);
if (response.status <= 200) {
if (response.status < 400) {
return true;
}
}
@ -63,7 +70,7 @@ export const useShiftStore = defineStore('shift_store', () => {
}
return {
shift_error,
shift_errors,
deleteShiftById,
createNewShifts,
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 { Timesheet } from 'src/modules/timesheets/models/timesheet.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', () => {
@ -15,7 +16,8 @@ export const useTimesheetStore = defineStore('timesheet', () => {
const pay_period = ref<PayPeriod>();
const pay_period_overviews = 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 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;
if (pay_period.value === undefined) return;
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;
initial_timesheets.value = unwrapAndClone(timesheets.value);
is_loading.value = false;
} catch (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,
current_pay_period_overview,
timesheets,
initial_timesheets,
getPayPeriodByDateOrYearAndNumber,
getTimesheetOverviewsByPayPeriod,
getTimesheetsByEmployeeEmail,