Merge pull request 'dev/nicolas/timesheet-gui-refactor' (#23) from dev/nicolas/timesheet-gui-refactor into main

Reviewed-on: Targo/targo_frontend#23
This commit is contained in:
Nicolas 2025-11-11 12:48:47 -05:00
commit dccf5a8d82
44 changed files with 1338 additions and 711 deletions

View File

@ -7,15 +7,12 @@ WORKDIR /app
# Set environment variables
ENV VITE_TARGO_BACKEND_URL="http://targo-backend:3000"
# Copy package.json & package-lock.json first (for caching)
COPY package*.json ./
# Copy the code
COPY . .
# Install dependencies
RUN npm install
# Copy the rest of the code
COPY . .
# Expose Quasar dev port
EXPOSE 9000

View File

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

View File

@ -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 : #AAD5C4;
$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

@ -66,6 +66,11 @@ export default {
dark_mode: "dark",
light_mode: "light",
},
schedule_presets: {
tab_title: "Schedule",
selected_schedule: "Selected Schedule Preset",
new_preset: "Build a new preset",
},
errors: {
must_enter_birthdate: "You must enter a valid birthdate",
}
@ -151,12 +156,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 +202,7 @@ export default {
PER_DIEM:"Per Diem",
EXPENSES:"expense",
MILEAGE:"mileage",
PRIME_GARDE:"on-call allowance",
ON_CALL:"on-call allowance",
},
},
},

View File

@ -66,6 +66,12 @@ export default {
dark_mode: "sombre",
light_mode: "clair",
},
schedule_presets: {
tab_title: "horaire",
selected_schedule: "Horaire Sélectionné",
new_preset: "Construire un nouvel horaire",
},
errors: {
must_enter_birthdate: "Vous devez entrer une date de naissance valide",
}
@ -151,12 +157,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 +203,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

@ -34,7 +34,7 @@
v-model="email"
dense
outlined
label-color="primary"
label-color="accent"
class="rounded-5 inset-shadow bg-blue-grey-1"
label-slot
input-class="text-weight-medium text-h6"
@ -44,12 +44,28 @@
</template>
</q-input>
<q-card-section class="q-ma-none q-pa-none text-uppercase text-caption text-weight-medium">
<q-card-section
horizontal
class="q-mb-md q-pa-none text-uppercase text-caption text-weight-medium"
>
<q-toggle
v-model="is_remembered"
color="primary"
:label="$t('login.button.remember_me')"
size="sm"
color="accent"
class="col-auto"
/>
<transition
enter-active-class="animated rubberBand fast"
leave-active-class=""
mode="out-in"
>
<span
:key="is_remembered ? 'yep' : 'nope'"
class="col-auto text-weight-bold self-center q-ml-sm"
:class="is_remembered ? 'text-accent' : ''"
>{{ $t('login.button.remember_me') }}</span>
</transition>
</q-card-section>
<q-card-actions>
@ -58,7 +74,7 @@
rounded
disabled
type="submit"
color="primary"
color="accent"
:label="$t('login.button.connect')"
class="full-width"
/>
@ -72,12 +88,14 @@
<q-card-section class="row q-pt-sm">
<q-separator
size="2px"
color="primary"
class="col self-center"
/>
<span class="col text-primary text-weight-bolder text-center text-uppercase self-center">{{
$t('shared.misc.or') }}</span>
<q-separator
size="2px"
color="primary"
class="col self-center"
/>
@ -108,7 +126,7 @@
<q-btn
push
rounded
color="primary"
color="accent"
icon="img:src/assets/logo-targo-simple.svg"
:label="$t('login.button.employee')"
class="full-width row"

View File

@ -5,13 +5,18 @@
import MenuPanelPersonal from 'src/modules/profile/components/employee/menu-panel-personal.vue';
import MenuPanelEmployee from 'src/modules/profile/components/employee/menu-panel-employee.vue';
import MenuPanelPreferences from 'src/modules/profile/components/shared/menu-panel-preferences.vue';
import MenuPanelSchedulePresets from 'src/modules/profile/components/shared/menu-panel-schedule-presets.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',
EMPLOYEE_INFO: 'employee_info',
PREFERENCES: 'references',
SCHEDULE_PRESETS: 'schedule_presets',
};
const employee_profile = defineModel<EmployeeProfile>({ default: default_employee_profile });
@ -24,8 +29,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>
@ -44,6 +49,11 @@
icon="display_settings"
:label="$q.screen.lt.md ? '' : $t('profile.preferences.tab_title')"
/>
<q-tab
:name="PanelNames.SCHEDULE_PRESETS"
icon="list_alt"
:label="$q.screen.lt.md ? '' : $t('profile.schedule_presets.tab_title')"
/>
</template>
<template #panels>
@ -67,6 +77,12 @@
>
<MenuPanelPreferences />
</q-tab-panel>
<q-tab-panel
:name="PanelNames.SCHEDULE_PRESETS"
class="q-pa-none"
>
<MenuPanelSchedulePresets />
</q-tab-panel>
</template>
</MenuTemplate>
</q-card>

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

@ -0,0 +1,45 @@
<script setup lang="ts">
const model = defineModel<string>();
</script>
<template>
<q-form class="q-pa-md column fit">
<div
class="col-auto text-uppercase rounded-5"
style="line-height: 1em;"
>{{ $t('profile.schedule_presets.selected_schedule') }}</div>
<!-- where the selected preset schedule will be displayed-->
<q-card
flat
class="col-auto column justify-center items-center content-center q-mb-lg q-pa-md"
style="border: solid #AAA 1px;"
>
<!-- will display the list of schedules by name -->
<q-select v-model="model" />
<!-- will display a list with details about the selected schedule -->
<q-card-section>
<q-list>
</q-list>
</q-card-section>
</q-card>
<div
class="col-auto text-uppercase rounded-5"
style="line-height: 1em;"
>{{ $t('profile.schedule_presets.new_preset') }}</div>
<!-- where the user will be able to create a new preset schedule -->
<q-card
flat
class="col-auto column justify-center items-center content-center q-mb-lg q-pa-md"
style="border: solid #AAA 1px;"
>
<q-form>
</q-form>
</q-card>
</q-form>
</template>

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

@ -0,0 +1,27 @@
export interface SchedulePreset {
name: string;
is_default: boolean;
presets_shifts: ShiftPreset[];
}
class ShiftPreset {
week_day: Weekday;
preset_id: number;
sort_order: number;
type: string;
start_time: string;
end_time: string;
is_remote: boolean;
constructor() {
this.week_day = '';
this.preset_id = -1;
this.sort_order = -1;
this.type = '';
this.start_time = '';
this.end_time = '';
this.is_remote = false;
}
}
export type Weekday = 'SUN' | 'MON' | 'TUE' | 'WED' | 'THU' | 'FRI' | 'SAT' | '';

View File

@ -0,0 +1,29 @@
import { api } from "src/boot/axios";
import type { SchedulePreset } from "src/modules/profile/models/schedule-presets.models";
export const SchedulePresetsService = {
createSchedulePresets: async (new_schedule: SchedulePreset) => {
const response = await api.post(`/schedule-presets/create/`, new_schedule);
return response.data;
},
updateSchedulePresets: async (preset_id: number, dto: Partial<SchedulePreset>) => {
const response = await api.patch(`/schedule-presets/update/${preset_id}`, dto);
return response.data;
},
deleteSchedulePresets: async (preset_id: number) => {
const response = await api.delete(`/schedule-presets/delete/${preset_id}`);
return response.data;
},
findListOfSchedulePresets: async () => {
const response = await api.get(`/schedule-presets/find-list`);
return response.data;
},
applyPresets: async (preset_name: string, start_date: string) => {
const response = await api.post(`/schedule-presets/apply-presets/`, { preset: preset_name, start: start_date });
return response.data;
},
};

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

@ -5,6 +5,7 @@
const NEXT = 1;
const PREVIOUS = -1;
const PAY_PERIOD_DATE_LIMIT = '2023/12/17';
const timesheet_store = useTimesheetStore();
@ -63,7 +64,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 +82,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 +100,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"
@ -125,7 +126,7 @@
class="q-mt-xl"
today-btn
mask="YYYY-MM-DD"
:options="date => date > '2023/12/16'"
:options="date => date >= PAY_PERIOD_DATE_LIMIT"
@update:model-value="onDateSelected"
/>
</q-dialog>

View File

@ -2,14 +2,14 @@
setup
lang="ts"
>
/* eslint-disable */
import { inject, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useExpensesStore } from 'src/stores/expense-store';
import { empty_expense, EXPENSE_TYPE, TYPES_WITH_AMOUNT_ONLY } from 'src/modules/timesheets/models/expense.models';
import { Expense, EXPENSE_TYPE, TYPES_WITH_AMOUNT_ONLY } from 'src/modules/timesheets/models/expense.models';
import { useExpenseRules } from 'src/modules/timesheets/utils/expense.util';
import { useExpensesApi } from 'src/modules/timesheets/composables/use-expense-api';
import { useTimesheetStore } from 'src/stores/timesheet-store';
import { date } from 'quasar';
const { t } = useI18n();
@ -18,61 +18,79 @@
const expenses_api = useExpensesApi();
const files = defineModel<File[] | null>('files');
const is_navigator_open = ref(false);
const mode = ref<'create' | 'update' | 'delete'>('create');
const COMMENT_MAX_LENGTH = 280;
const employee_email = inject<string>('employeeEmail');
const rules = useExpenseRules(t);
const cancelUpdateMode = () => {
expenses_store.current_expense = empty_expense;
expenses_store.initial_expense = empty_expense;
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(date.formatDate(new Date(), 'YYYY-MM-DD'));
expenses_store.initial_expense = new Expense(date.formatDate(new Date(), 'YYYY-MM-DD'));
expenses_store.mode = 'create';
};
const requestExpenseCreationOrUpdate = async () => {
if (mode.value === 'create') await expenses_api.createExpenseByEmployeeEmail(employee_email ?? '', expenses_store.current_expense?.date ?? '');
if (expenses_store.mode === 'create') await expenses_api.createExpenseByEmployeeEmail(employee_email ?? '', expenses_store.current_expense?.date ?? '');
else await expenses_api.updateExpenseByEmployeeEmail(employee_email ?? '', expenses_store.current_expense?.date ?? '');
};
const getExpenseCalendarRange = (current_date: string) => {
const period = timesheet_store.pay_period;
if (period !== undefined) return current_date >= period.period_start && current_date <= period.period_end;
return false;
}
</script>
<template>
<q-form
flat
v-if="!timesheet_store.timesheets?.every(timesheet => timesheet.is_approved)"
: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"
:class="mode === 'update' ? 'bg-accent' : ''"
>
<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"
dense
filled
outlined
readonly
stack-label
class="col q-px-xs"
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"
:options="getExpenseCalendarRange"
@update:model-value="is_navigator_open = false"
/>
</q-dialog>
</template>
@ -82,19 +100,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"
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,18 +126,23 @@
input-class="text-right"
dense
stack-label
clearable
color="primary"
class="col q-px-xs"
: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"
@ -124,31 +153,37 @@
clearable
color="primary"
class="col q-px-xs"
: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
v-model="expenses_store.current_expense.comment"
filled
dense
stack-label
label-slot
color="primary"
type="text"
class="col q-px-sm"
dense
stack-label
clearable
: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>
@ -156,14 +191,14 @@
<!-- import attach file section -->
<q-file
v-model="files"
:label="$t('timesheet.expense.hints.attach_file')"
dense
filled
use-chips
multiple
stack-label
:label="$t('timesheet.expense.hints.attach_file')"
class="col"
style="max-width: 300px;"
dense
>
<template #prepend>
<q-icon
@ -173,28 +208,29 @@
/>
</template>
</q-file>
</div>
<div class="col row full-width items-center">
<q-space />
<!-- add btn section -->
<div>
<q-btn
v-if="mode === 'update'"
v-if="expenses_store.mode === 'update'"
flat
dense
size="sm"
class="q-mt-sm q-ml-sm"
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="add"
size="sm"
class="q-mt-sm q-ml-sm"
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')) : ''"
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

@ -2,60 +2,56 @@
setup
lang="ts"
>
/* eslint-disable */
import { computed, inject, ref } from 'vue';
import { date } from 'quasar';
import { computed, 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 { useTimesheetStore } from 'src/stores/timesheet-store';
import { getExpenseIcon } from 'src/modules/timesheets/utils/expense.util';
import { useAuthStore } from 'src/stores/auth-store';
import { CAN_APPROVE_PAY_PERIODS } from 'src/modules/shared/models/user.models';
import { empty_expense, type Expense } from 'src/modules/timesheets/models/expense.models';
import { Expense } from 'src/modules/timesheets/models/expense.models';
const { expense, horizontal = false } = defineProps<{
expense: Expense;
index: number;
horizontal?: boolean;
}>();
const is_approved = defineModel<boolean>({ required: true });
const timesheet_store = useTimesheetStore();
const expenses_store = useExpensesStore();
const auth_store = useAuthStore();
const expenses_api = useExpensesApi();
const is_approved = defineModel<boolean>({ required: true });
const is_selected = ref(false);
const refresh_key = ref(1);
const background_class = computed(() => deepEqual(expense, expenses_store.current_expense) ? '' : '');
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 expenseItemStyle = computed(() => is_approved.value ? 'border: solid 2px var(--q-primary);' : 'border: solid 2px grey;');
// const highlightClass = computed(() => (expenses_store.mode === 'update' && is_selected) ? 'bg-accent' : '');
const approvedClass = computed(() => horizontal ? ' q-mx-xs q-pa-xs cursor-pointer' : '')
const employeeEmail = inject<string>('employeeEmail') ?? '';
const setExpenseToModify = () => {
// expenses_store.mode = 'update';
expenses_store.current_expense = expense;
expenses_store.initial_expense = unwrapAndClone(expense);
};
const requestExpenseDeletion = async () => {
// expenses_store.mode = 'delete';
expenses_store.initial_expense = expense;
expenses_store.current_expense = empty_expense;
await expenses_api.deleteExpenseByEmployeeEmail(employeeEmail, expenses_store.initial_expense.date);
await expenses_api.deleteExpenseById(expense.id);
}
function onExpenseClicked() {
const onExpenseClicked = () => {
if (is_authorized_to_approve.value) {
is_approved.value = !is_approved.value;
refresh_key.value += 1;
}
}
const onUpdateClicked = () => {
if (deepEqual(expense, expenses_store.current_expense)) {
expenses_store.mode = 'create';
expenses_store.current_expense = new Expense(date.formatDate(new Date(), 'YYYY-MM-DD'));
return;
}
expenses_store.mode = 'update';
expenses_store.current_expense = expense;
expenses_store.initial_expense = unwrapAndClone(expense);
}
</script>
<template>
@ -66,27 +62,16 @@
<q-item
:key="refresh_key"
:clickable="horizontal"
class="row col-4 q-ma-xs shadow-2"
:style="expenseItemStyle + approvedClass"
class="row col-4 items-center q-my-sm q-py-none shadow-3 rounded-5 bg-dark"
:class="background_class + approved_class"
: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
@ -97,14 +82,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-auto">
<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
@ -121,37 +106,45 @@
<q-item-label
caption
lines="1"
class="text-uppercase text-weight-light"
:class="approved_class"
>
<!-- {{ $d(new Date(expense.date), { year: 'numeric', month: 'short', day: 'numeric', weekday: 'short' }) }} -->
{{ expense.date }}
{{ $d(new Date(expense.date), { month: 'short', day: 'numeric', weekday: 'long' }) }}
</q-item-label>
</q-item-section>
<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>
@ -159,10 +152,13 @@
<!-- 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
@ -174,27 +170,27 @@
</q-item-label>
</q-item-section>
<q-item-section
side
class="q-pa-none"
>
<q-item-section :side="$q.screen.gt.sm">
<q-btn
push
dense
size="xs"
color="primary"
flat
size="lg"
icon="edit"
class="q-mb-xs z-top"
@click.stop="setExpenseToModify"
color="accent"
:disable="expense.is_approved"
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
size="xs"
color="negative"
icon="close"
class="z-top"
flat
size="lg"
:icon="expense.is_approved ? 'verified' : 'close'"
:color="expense.is_approved ? 'white' : 'negative'"
class="q-pa-none z-top"
:class="expense.is_approved ? 'no-pointer' : ''"
@click.stop="requestExpenseDeletion"
/>
</q-item-section>

View File

@ -2,32 +2,41 @@
setup
lang="ts"
>
import { useExpensesStore } from 'src/stores/expense-store';
import { computed } from 'vue';
import { useTimesheetStore } from 'src/stores/timesheet-store';
import ExpenseDialogListItem from 'src/modules/timesheets/components/expense-dialog-list-item.vue';
const expenses_store = useExpensesStore();
const timesheet_store = useTimesheetStore();
const { horizontal = false } = defineProps<{
horizontal?: boolean;
}>();
const expenses_list = computed(() => {
if (timesheet_store.timesheets !== undefined) {
return timesheet_store.timesheets.flatMap(week => week.days).flatMap(day => day.expenses);
}
return [];
})
</script>
<template>
<!-- liste des dépenses pré existantes -->
<q-list
padding
class="rounded-borders"
class="q-px-lg"
:class="horizontal ? 'row flex-center' : ''"
>
<q-item-label
v-if="expenses_store.pay_period_expenses?.length === 0"
v-if="expenses_list.length > 0"
class="text-italic q-px-sm"
>
{{ $t('timesheet.expense.empty_list') }}
</q-item-label>
<ExpenseDialogListItem
v-for="(expense, index) in expenses_store.pay_period_expenses"
v-for="(expense, index) in expenses_list"
:key="index"
v-model="expense.is_approved"
:index="index"

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
@ -36,6 +38,12 @@
<ExpenseDialogList />
<transition
appear
enter-active-class="animated fadeInDown faster"
leave-active-class="animated fadeOutDown faster"
mode="out-in"
>
<ExpenseDialogForm v-if="!expense_store.current_expense.is_approved" />
<q-icon
v-else
@ -43,22 +51,8 @@
color="negative"
size="lg"
/>
<q-separator spaced />
</transition>
</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,265 @@
<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,197 +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;
};
onMounted(() => {
if (ui_store.focus_next_component) {
select_ref.value?.focus();
select_ref.value?.showPopup();
ui_store.focus_next_component = false;
}
});
</script>
<template>
<div
v-if="shift.shift_id !== 0"
class="col row flex-center text-uppercase rounded-10"
>
<!-- shift type -->
<q-select
ref="select"
v-model="shift_type_selected"
standout="bg-blue-grey-9"
dense
options-dense
hide-dropdown-icon
:menu-offset="[0, 10]"
:options="options"
class="rounded-5 q-mx-xs shadow-1"
:class="ui_store.is_mobile_mode ? 'col-auto' : 'col'"
popup-content-class="text-uppercase text-weight-bold text-center rounded-5"
popup-content-style="border: 2px solid var(--q-primary)"
>
<template #selected-item="scope">
<div
class="row text-weight-bold q-ma-none q-pa-none no-wrap ellipsis"
:class="ui_store.is_mobile_mode ? 'items-center' : 'flex-center'"
: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 timestamp -->
<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 timestamps -->
<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-mr-xs"
/>
<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 q-pl-md 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,87 +23,135 @@
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>
<template>
<div :class="$q.screen.lt.md ? 'column' : 'row'">
<div
v-for="timesheet in timesheet_store.timesheets"
:class="$q.screen.lt.md ? 'column full-width' : 'row'"
:style="$q.screen.lt.md ? 'width: 90vw !important;' : ''"
>
<div
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)"
/>
@ -121,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,12 +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 ShiftListLegend from 'src/modules/timesheets/components/shift-list-legend.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();
@ -82,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"
@ -94,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"
@ -102,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

@ -16,13 +16,13 @@ export const useExpensesApi = () => {
// await expenses_store.upsertOrDeleteExpensesByEmployeeEmail(employee_email, date, upsert_expense);
};
const deleteExpenseByEmployeeEmail = async (employee_email: string, date: string): Promise<void> => {
// await expenses_store.upsertOrDeleteExpensesByEmployeeEmail(employee_email, date, upsert_expense);
const deleteExpenseById = async (expense_id: number): Promise<void> => {
await expenses_store.deleteExpenseById(expense_id);
};
return {
createExpenseByEmployeeEmail,
updateExpenseByEmployeeEmail,
deleteExpenseByEmployeeEmail,
deleteExpenseById,
};
};

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

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

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,13 +1,19 @@
import { api } from "src/boot/axios";
import type { Expense } from "src/modules/timesheets/models/expense.models";
export const ExpenseService = {
getExpensesByTimesheetId: async (timesheet_id: number) => {
const response = await api.get(`timesheet/${timesheet_id}`);
createExpense: async (expense: Expense) => {
const response = await api.post('expense/create', expense);
return response.data;
},
upsertOrDeleteExpenseById: async (expense_id: number) => {
const response = await api.post(`epxense/${expense_id}`);
updateExpenseById: async (expense: Expense) => {
const response = await api.patch(`expense/update`, expense);
return response.data;
},
deleteExpenseById: async (expense_id: number): Promise<{ok: boolean, id: number, error?: unknown}> => {
const response = await api.delete(`expense/delete/${expense_id}`);
return response.data;
}
};

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

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

View File

@ -1,92 +1,56 @@
import { ref } from "vue";
import { defineStore } from "pinia";
import { ExpensesApiError, type GenericApiError } from "src/modules/timesheets/models/expense-validation.models";
import { empty_expense, test_expenses, type Expense } from "src/modules/timesheets/models/expense.models";
import { Expense } from "src/modules/timesheets/models/expense.models";
import { ExpenseService } from "src/modules/timesheets/services/expense-service";
import { date } from "quasar";
export const useExpensesStore = defineStore('expenses', () => {
const is_open = ref(false);
const is_loading = ref(false);
const pay_period_expenses = ref<Expense[]>(test_expenses);
const current_expense = ref<Expense>(empty_expense);
const initial_expense = ref<Expense>(empty_expense);
const error = ref<string | null>(null);
// const setErrorFrom = (err: unknown) => {
// const e = err as any;
// error.value = e?.message || 'Unknown error';
// };
const mode = ref<'create' | 'update' | 'delete'>('create');
const current_expense = ref<Expense>(new Expense(date.formatDate(new Date(), 'YYYY-MM-DD')));
const initial_expense = ref<Expense>(new Expense(date.formatDate(new Date(), 'YYYY-MM-DD')));
const open = (): void => {
is_open.value = true;
is_loading.value = true;
error.value = null;
current_expense.value = empty_expense;
initial_expense.value = empty_expense;
// await getPayPeriodExpensesByTimesheetId(timesheet_id);
is_loading.value = false;
current_expense.value = new Expense(date.formatDate(new Date(), 'YYYY-MM-DD'));
initial_expense.value = new Expense(date.formatDate(new Date(), 'YYYY-MM-DD'));
mode.value = 'create';
}
const close = () => {
error.value = null;
is_open.value = false;
};
const getPayPeriodExpensesByTimesheetId = async (timesheet_id: number): Promise<void> => {
is_loading.value = true;
error.value = null;
const upsertExpensesById = async (expense_id: number, expense: Expense): Promise<void> => {
try {
const expenses = await ExpenseService.getExpensesByTimesheetId(timesheet_id);
pay_period_expenses.value = expenses;
} catch (err: unknown) {
if (typeof err === 'object') {
const error = err as GenericApiError;
const status_code: number = error.status_code ?? 500;
// const data = error.context ?? '';
// error.value = data.message || data.error || err.message;
throw new ExpensesApiError({
status_code,
// error_code: data.error_code,
// message: data.message || data.error || err.message,
// context: data.context,
});
if (expense_id < 0) {
const data = await ExpenseService.createExpense(expense);
return data;
}
} finally {
is_loading.value = false;
}
};
const upsertOrDeleteExpensesById = async (expense_id: number): Promise<void> => {
is_loading.value = true;
error.value = null;
try {
await ExpenseService.upsertOrDeleteExpenseById(expense_id);
// TODO: Save response data into proper ref
} catch (err) {
// setErrorFrom(err);
console.error(err);
} finally {
is_loading.value = false;
}
};
const deleteExpenseById = async (expense_id: number): Promise<boolean> => {
const data = await ExpenseService.deleteExpenseById(expense_id);
return data.ok;
}
return {
is_open,
is_loading,
pay_period_expenses,
mode,
current_expense,
initial_expense,
error,
open,
getPayPeriodExpensesByTimesheetId,
upsertOrDeleteExpensesById,
upsertExpensesById,
deleteExpenseById,
close,
};
});

View File

@ -0,0 +1,68 @@
/* eslint-disable */
import { ref } from "vue";
import { defineStore } from "pinia";
import { SchedulePresetsService } from "src/modules/profile/services/schedule-presets-service";
import type { SchedulePreset } from "src/modules/profile/models/schedule-presets.models";
export const useSchedulePresetsStore = defineStore('schedule_presets_store', () => {
const schedule_presets = ref<SchedulePreset>();
const createSchedulePreset = async (): Promise<boolean> => {
try {
// const new_preset: SchedulePreset = ??
// await SchedulePresetsService.createSchedulePresets(new_preset);
return true;
} catch (error) {
console.error('DEV ERROR || error while creating schedule preset: ', error);
return false;
}
}
const updateSchedulePreset = async (): Promise<boolean> => {
try {
return true;
} catch (error) {
console.error('DEV ERROR || error while updating schedule preset: ', error);
return false;
}
}
const deleteSchedulePreset = async (preset_id: number): Promise<boolean> => {
try {
await SchedulePresetsService.deleteSchedulePresets(preset_id);
return true;
} catch (error) {
console.error('DEV ERROR || error while deleting schedule preset: ', error);
return false
}
}
const findSchedulePresetList = async (): Promise<boolean> => {
try {
return true;
} catch (error) {
console.error('DEV ERROR || error while searching for schedule presets: ', error);
return false
}
}
const applySchedulePreset = async (): Promise<boolean> => {
try {
return true;
} catch (error) {
console.error('DEV ERROR || error while building schedule: ', error);
return false
}
}
return {
schedule_presets,
createSchedulePreset,
updateSchedulePreset,
deleteSchedulePreset,
findSchedulePresetList,
applySchedulePreset,
}
})

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

@ -1,6 +1,7 @@
import { ref } from 'vue';
import { defineStore } from 'pinia';
import { useAuthStore } from 'src/stores/auth-store';
import { unwrapAndClone } from 'src/utils/unwrap-and-clone';
import { timesheetApprovalService } from 'src/modules/timesheet-approval/services/timesheet-approval-service';
import { timesheetService } from 'src/modules/timesheets/services/timesheet-service';
import type { TimesheetOverview } from "src/modules/timesheet-approval/models/timesheet-overview.models";
@ -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,