Merge branch 'main' into dev/lion/chatbot

This commit is contained in:
Lion Arar 2025-10-07 14:38:45 -04:00
commit fe553b3cca
113 changed files with 2937 additions and 4271 deletions

View File

@ -106,7 +106,7 @@ export default defineConfig((ctx) => {
color: 'primary', color: 'primary',
avatar: 'https://cdn.quasar.dev/img/boy-avatar.png', avatar: 'https://cdn.quasar.dev/img/boy-avatar.png',
}, },
dark: "auto", dark: false,
}, },
// iconSet: 'material-icons', // Quasar icon set // iconSet: 'material-icons', // Quasar icon set
@ -127,15 +127,7 @@ export default defineConfig((ctx) => {
// animations: 'all', // --- includes all animations // animations: 'all', // --- includes all animations
// https://v2.quasar.dev/options/animations // https://v2.quasar.dev/options/animations
animations: [ animations: 'all',
'fadeIn',
'fadeOut',
'fadeInUp',
'zoomIn',
'zoomOut',
'flipInX',
'flipOutX',
],
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#sourcefiles // https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#sourcefiles
// sourceFiles: { // sourceFiles: {

View File

@ -31,5 +31,5 @@ body.body--dark {
.body--light { .body--light {
--q-dark: #FFF; --q-dark: #FFF;
color: $grey-8; color: $blue-grey-8;
} }

View File

@ -74,6 +74,7 @@ export default {
}, },
label: { label: {
search: "search", search: "search",
filter: "filters",
loading: "loading...", loading: "loading...",
language: "Language", language: "Language",
add: "ajouter", add: "ajouter",
@ -82,6 +83,7 @@ export default {
cancel: "cancel", cancel: "cancel",
update: "update", update: "update",
modify: "modify", modify: "modify",
close: "close",
}, },
misc: { misc: {
or: "or", or: "or",

View File

@ -74,6 +74,7 @@ export default {
}, },
label: { label: {
search: 'recherche', search: 'recherche',
filter: "filtres",
loading: 'chargement en cours...', loading: 'chargement en cours...',
language: 'langue', language: 'langue',
add: "ajouter", add: "ajouter",
@ -82,6 +83,7 @@ export default {
cancel: "annuler", cancel: "annuler",
update: "mettre à jour", update: "mettre à jour",
modify: "modifier", modify: "modifier",
close: "fermer",
}, },
misc: { misc: {
or: "ou", or: "ou",

View File

@ -1,6 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import { useUiStore } from 'src/stores/ui-store'; import { useUiStore } from 'src/stores/ui-store';
import HeaderBarAvatar from './header-bar-avatar.vue'; import HeaderBarNotification from './main-layout-header-bar-notification.vue';
import Chatbutton from "src/modules/chatbot/components/chat-button.vue"; import Chatbutton from "src/modules/chatbot/components/chat-button.vue";
const uiStore = useUiStore(); const uiStore = useUiStore();
@ -26,8 +26,12 @@ const uiStore = useUiStore();
</q-btn> </q-btn>
</q-toolbar-title> </q-toolbar-title>
<q-item class="q-pa-none"> <q-item class="q-pa-none">
<Chatbutton></Chatbutton>
<HeaderBarAvatar /> <Chatbutton>
</Chatbutton>
<HeaderBarNotification />
</q-item> </q-item>
</q-toolbar> </q-toolbar>
</q-header> </q-header>

View File

@ -26,8 +26,16 @@
</script> </script>
<template> <template>
<q-drawer overlay elevated side="left" :mini="miniState" @mouseenter="miniState = false" <q-drawer
@mouseleave="miniState = true" v-model="uiStore.isRightDrawerOpen"> v-model="uiStore.isRightDrawerOpen"
overlay
elevated
side="left"
:mini="miniState"
@mouseenter="miniState = false"
@mouseleave="miniState = true"
class="bg-dark"
>
<q-scroll-area class="fit"> <q-scroll-area class="fit">
<q-list> <q-list>
<!-- Home --> <!-- Home -->

View File

@ -1,15 +1,16 @@
<script lang="ts" setup> <script lang="ts" setup>
import { RouterView } from "vue-router"; import { RouterView } from 'vue-router';
import HeaderBar from "src/modules/shared/components/navigation/header-bar.vue"; import HeaderBar from 'src/layouts/components/main-layout-header-bar.vue';
import FooterBar from "src/modules/shared/components/navigation/footer-bar.vue"; import FooterBar from 'src/layouts/components/main-layout-footer-bar.vue';
import RightDrawer from "src/modules/shared/components/navigation/right-drawer.vue"; import LeftDrawer from 'src/layouts/components/main-layout-left-drawer.vue';
import ChatbotPage from "src/modules/chatbot/pages/chatbot-page.vue"; import ChatbotPage from "src/modules/chatbot/pages/chatbot-page.vue";
</script> </script>
<template> <template>
<q-layout view="hHh lpR fFf"> <q-layout view="hHh lpR fFf">
<HeaderBar /> <HeaderBar />
<RightDrawer /> <LeftDrawer />
<q-page-container> <q-page-container>
<router-view class="q-pa-sm bg-secondary" /> <router-view class="q-pa-sm bg-secondary" />
<ChatbotPage /> <ChatbotPage />

View File

@ -1,4 +1,4 @@
import type { User } from "src/modules/shared/types/user-interface"; import type { User } from "src/modules/shared/models/user.models";
export interface AuthState { export interface AuthState {
token: string; token: string;

View File

@ -0,0 +1,30 @@
<script setup lang="ts">
const { title, startDate = "", endDate = "" } = defineProps<{
title: string;
startDate?: string;
endDate?: string;
}>();
const date_format_options = { day: 'numeric', month: 'long', year: 'numeric', };
</script>
<template>
<div class="column q-mt-lg text-uppercase text-center text-weight-bolder text-h4">
<span class="col">{{ $t(title) }}</span>
<div
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">
{{ $d(new Date(startDate), 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">
{{ $d(new Date(endDate), date_format_options) }}
</div>
</div>
</div>
</template>

View File

@ -1,71 +1,76 @@
<script setup lang="ts"> <script setup lang="ts">
/* eslint-disable */ import { computed, ref } from 'vue';
import { ref } from 'vue';
import { date} from 'quasar'; import { date} from 'quasar';
import type { QDateDetails } from 'src/modules/shared/types/q-date-details'; import { useTimesheetStore } from 'src/stores/timesheet-store';
const timesheet_store = useTimesheetStore();
const is_showing_calendar_picker = ref(false); const is_showing_calendar_picker = ref(false);
const calendar_date = ref(date.formatDate( Date.now(), 'YYYY-MM-DD' )); const calendar_date = ref(date.formatDate( Date.now(), 'YYYY-MM-DD' ));
const props = defineProps<{
isDisabled?: boolean;
isPreviousLimit:boolean;
}>();
const emit = defineEmits<{ const emit = defineEmits<{
'date-selected': [value: string, reason?: string, details?: QDateDetails] 'date-selected': [ value: string ]
'pressed-previous-button': [] 'pressed-previous-button': []
'pressed-next-button': [] 'pressed-next-button': []
}>(); }>();
const onDateSelected = (value: string, reason: string, details: QDateDetails) => { const is_previous_pay_period_limit = computed( ()=>
timesheet_store.pay_period.pay_year === 2024 &&
timesheet_store.pay_period.pay_period_no <= 1
);
const onDateSelected = (value: string) => {
calendar_date.value = value; calendar_date.value = value;
is_showing_calendar_picker.value = false; is_showing_calendar_picker.value = false;
emit('date-selected', value, reason, details); emit('date-selected', value);
}; };
</script> </script>
<template> <template>
<div class="row q-mb-lg q-mt-lg" > <div class="row" >
<!-- navigation to previous week --> <!-- navigation to previous week -->
<q-btn <q-btn
push rounded push rounded
icon="keyboard_arrow_left" icon="keyboard_arrow_left"
color="primary" color="primary"
@click="emit('pressed-previous-button')" @click="emit('pressed-previous-button')"
:disable="props.isPreviousLimit || props.isDisabled" :disable="is_previous_pay_period_limit || timesheet_store.is_loading"
class="q-mr-sm q-px-sm" class="q-mr-sm q-px-sm"
> >
<q-tooltip <q-tooltip
anchor="top middle" anchor="top middle"
self="center middle" self="center middle"
class="bg-primary text-uppercase text-weight-bold" class="bg-primary text-uppercase text-weight-bold"
> {{ $t( 'timesheet.nav_button.previous_week' )}} >
{{ $t( 'timesheet.nav_button.previous_week' )}}
</q-tooltip> </q-tooltip>
</q-btn> </q-btn>
<!-- navigation through calendar date picker --> <!-- navigation through calendar date picker -->
<q-btn <q-btn
push rounded push rounded
icon="calendar_month" icon="calendar_month"
color="primary" color="primary"
@click="is_showing_calendar_picker = true" @click="is_showing_calendar_picker = true"
:disable="props.isDisabled" :disable="timesheet_store.is_loading"
class="q-px-lg" class="q-px-xl"
> >
<q-tooltip <q-tooltip
anchor="top middle" anchor="top middle"
self="center middle" self="center middle"
class="bg-primary text-uppercase text-weight-bold" class="bg-primary text-uppercase text-weight-bold"
>{{ $t('timesheet.nav_button.calendar_date_picker') }} >
{{ $t('timesheet.nav_button.calendar_date_picker') }}
</q-tooltip> </q-tooltip>
</q-btn> </q-btn>
<!-- navigation to next week --> <!-- navigation to next week -->
<q-btn <q-btn
push rounded push rounded
icon="keyboard_arrow_right" icon="keyboard_arrow_right"
color="primary" color="primary"
@click="emit('pressed-next-button')" @click="emit('pressed-next-button')"
:disable="props.isDisabled" :disable="timesheet_store.is_loading"
class="q-ml-sm q-px-sm" class="q-ml-sm q-px-sm"
> >
<q-tooltip <q-tooltip

View File

@ -0,0 +1,35 @@
<script setup lang="ts">
const search_model = defineModel<string | number | null>({ default: null, required: true });
</script>
<template>
<!-- Filters toggle -->
<q-btn-dropdown
push
rounded
class="q-mr-md bg-white text-primary"
:label="$t('shared.label.filter')"
icon="filter_alt"
/>
<!-- Search bar -->
<q-input
v-model="search_model"
outlined
dense
rounded
debounce="300"
class="right-rounded"
:label="$t('shared.label.search')"
label-color="primary"
bg-color="white"
color="primary"
>
<template #prepend>
<q-icon
name="search"
color="primary"
/>
</template>
</q-input>
</template>

View File

@ -1,67 +0,0 @@
<script setup lang="ts">
/* eslint-disable */
import { ref } from 'vue';
import { date } from 'quasar';
import type { QDateDetails } from 'src/modules/shared/types/q-date-details';
const is_showing_calendar_picker = ref(false);
const calendar_date = ref(date.formatDate( Date.now(), 'YYYY-MM-DD' ));
const props = defineProps<{
isDisabled: boolean,
isPreviousLimit: boolean,
}>();
const emit = defineEmits<{
'date-selected': [value: string, reason?: string, details?: QDateDetails]
'pressed-previous-button': []
'pressed-next-button': []
}>();
const onDateSelected = (value: string, reason: string, details: QDateDetails) => {
calendar_date.value = value;
is_showing_calendar_picker.value = false;
emit('date-selected', value, reason, details);
}
</script>
<template>
<div class="row justify-center">
<q-btn
push rounded
icon="keyboard_arrow_left"
color="primary"
@click="emit('pressed-previous-button')"
:disable="props.isPreviousLimit || props.isDisabled"
class="q-mr-sm q-px-sm"
/>
<q-btn
push rounded
icon="date_range"
color="primary"
@click="is_showing_calendar_picker = true"
:disable="props.isDisabled"
class="q-px-lg"
/>
<q-btn
push rounded
icon="keyboard_arrow_right"
color="primary"
@click="emit('pressed-next-button')"
:disable="props.isDisabled"
class="q-ml-sm q-px-sm"
/>
</div>
<q-dialog v-model="is_showing_calendar_picker" transition-show="jump-down" transition-hide="jump-up" position="top">
<q-date
v-model="calendar_date"
color="primary"
class="q-mt-xl"
today-btn
mask="YYYY-MM-DD"
:options="date => date > '2023/12/16'"
@update:model-value="onDateSelected"
/>
</q-dialog>
</template>

View File

@ -1,42 +0,0 @@
<script setup lang="ts">
const props = defineProps<{
searchModel: string | number | null
}>();
const emit = defineEmits<{
onSearchValueUpdated: [value: string | number | null]
}>();
</script>
<template>
<!-- Filters toggle -->
<q-btn-dropdown
rounded
push
class="q-mr-md bg-white text-primary"
label="filters"
icon="filter_alt"
/>
<!-- Search bar -->
<q-input
outlined
dense
rounded
debounce="300"
:label="$t('shared.label.search')"
label-color="primary"
bg-color="white"
color="primary"
:model-value="props.searchModel"
@update:model-value="value => emit('onSearchValueUpdated', value)"
>
<template v-slot:append>
<q-icon
name="search"
color="primary"
/>
</template>
</q-input>
</template>

View File

@ -1,17 +0,0 @@
import { ref } from "vue";
//date picker state
export const useToggle = (initial = false) => {
const state = ref<boolean>(initial);
const setTrue = () => { state.value = true; };
const setFalse = () => { state.value = false; };
const toggle = () => { state.value = !state.value; };
return {
state,
setTrue,
setFalse,
toggle
};
};

View File

@ -0,0 +1,17 @@
export interface PayPeriod {
pay_period_no: number;
period_start: string;
period_end: string;
payday: string;
pay_year: number;
label: string;
}
export const default_pay_period: PayPeriod = {
pay_period_no: -1,
period_start: '',
period_end: '',
payday: '',
pay_year: -1,
label: ''
};

View File

@ -1,8 +0,0 @@
export interface PayPeriod {
pay_period_no: number;
period_start: string;
period_end: string;
payday: string;
pay_year: number;
label: string;
}

View File

@ -1,15 +0,0 @@
export interface QDateDetails {
year: number;
month: number;
day: number;
from?: {
year: number;
month: number;
day: number;
};
to?: {
year: number;
month: number;
day: number;
};
}

View File

@ -0,0 +1,86 @@
<script
setup
lang="ts"
>
import { ref } from 'vue';
import { Bar } from 'vue-chartjs';
import { useI18n } from 'vue-i18n';
import { useQuasar } from 'quasar';
import { useTimesheetStore } from 'src/stores/timesheet-store';
import { Chart as ChartJS, Title, Tooltip, Legend, BarElement, CategoryScale, LinearScale, TimeScale, type ChartData, type ChartDataset } from 'chart.js';
const { t } = useI18n();
const $q = useQuasar();
ChartJS.register(Title, Tooltip, Legend, BarElement, CategoryScale, LinearScale, TimeScale);
ChartJS.defaults.font.family = '"Roboto", sans-serif';
ChartJS.defaults.maintainAspectRatio = false;
ChartJS.defaults.color = $q.dark.isActive ? '#F5F5F5' : '#616161';
const timesheet_store = useTimesheetStore();
const expenses_dataset = ref<ChartDataset<'bar'>[]>([]);
const expenses_labels = ref<string[]>([]);
const getExpensesData = (): ChartData<'bar'> => {
const all_days = timesheet_store.pay_period_details.weeks.flatMap(week => Object.values(week.expenses));
const all_days_dates = timesheet_store.pay_period_details.weeks.flatMap(week => Object.values(week.shifts))
const all_costs = all_days.map(day => day.total_expenses);
const all_mileage = all_days.map(day => day.total_mileage);
expenses_dataset.value = [
{
label: t('timesheet_approvals.table.expenses'),
data: all_costs,
backgroundColor: getComputedStyle(document.body).getPropertyValue('--q-primary').trim(),
},
{
label: t('timesheet_approvals.table.mileage'),
data: all_mileage,
backgroundColor: getComputedStyle(document.body).getPropertyValue('--q-info').trim(),
}
]
expenses_labels.value = all_days_dates.map(day => day.short_date);
return {
datasets: expenses_dataset.value,
labels: expenses_labels.value
};
};
</script>
<template>
<div>
<Bar
:data="getExpensesData()"
:options="({
indexAxis: $q.screen.lt.md ? 'y' : 'x',
plugins: {
title: {
display: true,
text: t('timesheet_approvals.chart.expenses_title'),
},
legend: {
labels: {
boxWidth: 15,
}
}
},
scales: {
x: {
stacked: true
},
y: {
suggestedMin: 0,
suggestedMax: 100,
stacked: true
}
}
})"
/>
</div>
</template>

View File

@ -4,8 +4,8 @@
import { Bar } from 'vue-chartjs'; import { Bar } from 'vue-chartjs';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useQuasar } from 'quasar'; import { useQuasar } from 'quasar';
import { Chart as ChartJS, Title, Tooltip, Legend, BarElement, CategoryScale, LinearScale, type ChartData, type ChartOptions, type Plugin, type ChartDataset } from 'chart.js'; import { Chart as ChartJS, Title, Tooltip, Legend, BarElement, CategoryScale, LinearScale, type ChartData, type ChartDataset } from 'chart.js';
import type { PayPeriodEmployeeDetails } from 'src/modules/timesheet-approval/types/timesheet-approval-pay-period-employee-details-interface'; import { useTimesheetStore } from 'src/stores/timesheet-store';
const { t } = useI18n(); const { t } = useI18n();
const $q = useQuasar(); const $q = useQuasar();
@ -15,19 +15,14 @@
ChartJS.defaults.maintainAspectRatio = false; ChartJS.defaults.maintainAspectRatio = false;
ChartJS.defaults.color = $q.dark.isActive ? '#F5F5F5' : '#616161'; ChartJS.defaults.color = $q.dark.isActive ? '#F5F5F5' : '#616161';
const props = defineProps<{ const { pay_period_details } = useTimesheetStore();
rawData: PayPeriodEmployeeDetails | undefined;
options?: ChartOptions<"bar"> | undefined;
plugins?: Plugin<"bar">[] | undefined;
}>();
const hours_worked_labels = ref<string[]>([]); const hours_worked_labels = ref<string[]>([]);
const hours_worked_dataset = ref<ChartDataset<'bar'>[]>([]); const hours_worked_dataset = ref<ChartDataset<'bar'>[]>([]);
const getHoursWorkedData = (): ChartData<'bar'> => { const getHoursWorkedData = (): ChartData<'bar'> => {
if (props.rawData) {
const all_weeks = [props.rawData.week1, props.rawData.week2]; const all_days = pay_period_details.weeks.flatMap( week => Object.values(week.shifts));
const all_days = all_weeks.flatMap( week => Object.values(week.shifts));
const datasetConfig = [ const datasetConfig = [
{ {
key: 'regular_hours', key: 'regular_hours',
@ -58,7 +53,7 @@
})); }));
hours_worked_labels.value = all_days.map(day => day.short_date); hours_worked_labels.value = all_days.map(day => day.short_date);
}
return { return {
labels: hours_worked_labels.value, labels: hours_worked_labels.value,

View File

@ -0,0 +1,70 @@
<script
setup
lang="ts"
>
import { ref } from 'vue';
import { colors } from 'quasar';
import { useQuasar } from 'quasar';
import { Doughnut } from 'vue-chartjs';
import { Chart as ChartJS, Title, Tooltip, Legend, ArcElement, CategoryScale, LinearScale, type ChartDataset } from 'chart.js';
import { useTimesheetStore } from 'src/stores/timesheet-store';
const $q = useQuasar();
ChartJS.register(Title, Tooltip, Legend, ArcElement, CategoryScale, LinearScale);
ChartJS.defaults.font.family = '"Roboto", sans-serif';
ChartJS.defaults.maintainAspectRatio = false;
ChartJS.defaults.color = $q.dark.isActive ? '#F5F5F5' : '#616161';
const { current_pay_period_overview } = useTimesheetStore();
const shift_type_labels = ref<string[]>([]);
const shift_type_totals = ref<ChartDataset<'doughnut'>[]>([{ data: [40, 0, 2, 5], }]);
shift_type_totals.value = [{
data: [
current_pay_period_overview.regular_hours,
current_pay_period_overview.evening_hours,
current_pay_period_overview.emergency_hours,
current_pay_period_overview.overtime_hours,
],
backgroundColor: [
colors.getPaletteColor('green-5'), // Regular
colors.getPaletteColor('green-9'), // Evening
getComputedStyle(document.body).getPropertyValue('--q-warning').trim(), // Emergency
getComputedStyle(document.body).getPropertyValue('--q-negative').trim(), // Overtime
]
}];
shift_type_labels.value = [
current_pay_period_overview.regular_hours.toString() + 'h',
current_pay_period_overview.evening_hours.toString() + 'h',
current_pay_period_overview.emergency_hours.toString() + 'h',
current_pay_period_overview.overtime_hours.toString() + 'h',
]
const data = {
labels: shift_type_labels.value,
datasets: shift_type_totals.value,
}
</script>
<template>
<div>
<Doughnut
:data="data"
:options="({
plugins: {
legend: {
labels: {
boxWidth: 15,
}
}
}
})"
/>
</div>
</template>

View File

@ -0,0 +1,128 @@
<script
setup
lang="ts"
>
import { ref } from 'vue';
import { useTimesheetStore } from 'src/stores/timesheet-store';
import DetailedDialogChartHoursWorked from 'src/modules/timesheet-approval/components/details-crud-dialog-chart-hours-worked.vue';
import DetailedDialogChartShiftTypes from 'src/modules/timesheet-approval/components/details-crud-dialog-chart-shift-types.vue';
import DetailedDialogChartExpenses from 'src/modules/timesheet-approval/components/details-crud-dialog-chart-expenses.vue';
import TimesheetWrapper from 'src/modules/timesheets/components/timesheet-wrapper.vue';
defineProps<{
employeeEmail: string;
}>();
const dialog_model = defineModel<boolean>('dialog', { default: false });
const timesheet_store = useTimesheetStore();
// const timesheet_store = useTimesheetStore();
const is_showing_graph = ref(true);
</script>
<template>
<q-dialog
v-model="dialog_model"
full-width
transition-show="jump-down"
transition-hide="jump-down"
>
<!-- loader -->
<q-card
v-if="timesheet_store.is_loading"
class="column flex-center text-center"
>
<q-spinner
color="primary"
size="5em"
:thickness="10"
class="col-auto"
/>
<div class="col-auto text-primary text-h6 text-weight-bold text-center ">
{{ $t('shared.loading') }}
</div>
</q-card>
<q-card
v-else
class="q-pa-sm shadow-12 rounded-15 column no-wrap relative"
:style="$q.screen.lt.md ? '' : 'width: 60vw !important;'"
>
<!-- employee name -->
<q-card-section
class="text-h5 text-weight-bolder text-center text-primary q-pa-none text-uppercase col-auto"
>
<span>{{ timesheet_store.pay_period_details.employee_full_name }}</span>
<q-separator
spaced
size="2px"
/>
<q-card-actions
align="center"
class="q-pa-none"
>
<q-btn-toggle
v-model="is_showing_graph"
color="white"
text-color="primary"
toggle-color="primary"
:options="[
{ icon: 'bar_chart', value: true },
{ icon: 'edit', value: false },
]"
/>
</q-card-actions>
</q-card-section>
<!-- employee timesheet for supervisor editting -->
<q-card-section
v-if="!is_showing_graph"
class="q-pa-none"
>
<!-- list of shifts -->
<q-card-section
:horizontal="$q.screen.gt.sm"
class="q-pa-none rounded-10"
>
<TimesheetWrapper
dense
:employee-email="employeeEmail"
/>
</q-card-section>
</q-card-section>
<!-- employee timesheet details with chart -->
<q-card-section
v-if="is_showing_graph"
class="q-pa-md col column full-width no-wrap"
>
<q-card-section
:horizontal="!$q.screen.lt.md"
class="q-pa-none col no-wrap"
style="min-height: 300px;"
>
<DetailedDialogChartHoursWorked class="col-7" />
<q-separator
spaced
:vertical="!$q.screen.lt.md"
/>
<div class="column col justify-center no-wrap q-pa-none">
<DetailedDialogChartShiftTypes class="col-5" />
<q-separator
spaced
:vertical="!$q.screen.lt.md"
/>
<DetailedDialogChartExpenses class="col" />
</div>
</q-card-section>
</q-card-section>
</q-card>
</q-dialog>
</template>

View File

@ -1,72 +0,0 @@
<script setup lang="ts">
/* eslint-disable */
import { ref } from 'vue';
import { colors } from 'quasar';
import { useI18n } from 'vue-i18n';
import { useQuasar } from 'quasar';
import { Doughnut } from 'vue-chartjs';
import { Chart as ChartJS, Title, Tooltip, Legend, ArcElement, CategoryScale, LinearScale, type ChartDataset } from 'chart.js';
import type { PayPeriodOverviewEmployee } from 'src/modules/timesheet-approval/types/timesheet-approval-pay-period-overview-employee-interface';
const { t } = useI18n();
const $q = useQuasar();
ChartJS.register(Title, Tooltip, Legend, ArcElement, CategoryScale, LinearScale);
ChartJS.defaults.font.family = '"Roboto", sans-serif';
ChartJS.defaults.maintainAspectRatio = false;
ChartJS.defaults.color = $q.dark.isActive ? '#F5F5F5' : '#616161';
const props = defineProps<{
rawData: PayPeriodOverviewEmployee | undefined;
}>();
const shift_type_labels = ref<string[]>([]);
const shift_type_totals = ref<ChartDataset<'doughnut'>[]>([{ data: [40, 0, 2, 5], }]);
if (props.rawData){
shift_type_totals.value = [{
data: [
props.rawData.regular_hours,
props.rawData.evening_hours,
props.rawData.emergency_hours,
props.rawData.overtime_hours,
],
backgroundColor: [
colors.getPaletteColor('green-5'), // Regular
colors.getPaletteColor('green-9'), // Evening
getComputedStyle(document.body).getPropertyValue('--q-warning').trim(), // Emergency
getComputedStyle(document.body).getPropertyValue('--q-negative').trim(), // Overtime
]
}];
shift_type_labels.value = [
props.rawData.regular_hours.toString() + 'h',
props.rawData.evening_hours.toString() + 'h',
props.rawData.emergency_hours.toString() + 'h',
props.rawData.overtime_hours.toString() + 'h',
]
}
const data = {
labels: shift_type_labels.value,
datasets: shift_type_totals.value,
}
</script>
<template>
<div>
<Doughnut
:data="data"
:options="({
plugins:{
legend:{
labels:{
boxWidth: 15,
}
}
}
})"
/>
</div>
</template>

View File

@ -1,99 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue';
import { Bar } from 'vue-chartjs';
import { useI18n } from 'vue-i18n';
import { useQuasar } from 'quasar';
import { Chart as ChartJS, Title, Tooltip, Legend, BarElement, CategoryScale, LinearScale, TimeScale, type ChartData, type ChartDataset } from 'chart.js';
import type { PayPeriodEmployeeDetails } from 'src/modules/timesheet-approval/types/timesheet-approval-pay-period-employee-details-interface';
import type { Expense } from 'src/modules/timesheets/types/expense.interfaces';
const { t } = useI18n();
const $q = useQuasar();
ChartJS.register(Title, Tooltip, Legend, BarElement, CategoryScale, LinearScale, TimeScale);
ChartJS.defaults.font.family = '"Roboto", sans-serif';
ChartJS.defaults.maintainAspectRatio = false;
ChartJS.defaults.color = $q.dark.isActive ? '#F5F5F5' : '#616161';
const props = defineProps<{
rawData: PayPeriodEmployeeDetails | undefined;
}>();
const expenses_dataset = ref<ChartDataset<'bar'>[]>([]);
const expenses_labels = ref<string[]>([]);
const getExpensesData = (): ChartData<'bar'> => {
if (props.rawData) {
const all_weeks = [props.rawData.week1, props.rawData.week2];
const all_days = all_weeks.flatMap( week => Object.values(week.expenses));
const all_days_dates = all_weeks.flatMap( week => Object.values(week.shifts))
const all_costs = all_days.map( day => getTotalAmounts(day.cash));
const all_mileage = all_days.map( day => getTotalAmounts(day.km));
expenses_dataset.value = [
{
label: t('timesheet_approvals.table.expenses'),
data: all_costs,
backgroundColor: getComputedStyle(document.body).getPropertyValue('--q-primary').trim(),
},
{
label: t('timesheet_approvals.table.mileage'),
data: all_mileage,
backgroundColor: getComputedStyle(document.body).getPropertyValue('--q-info').trim(),
}
]
expenses_labels.value = all_days_dates.map( day => day.short_date);
}
return {
datasets: expenses_dataset.value,
labels: expenses_labels.value
};
};
const getTotalAmounts = (expenses: Expense[]): number => {
let total_amount = 0;
for (const expense of expenses) {
total_amount += expense.amount;
}
return total_amount;
}
</script>
<template>
<div>
<Bar
:data="getExpensesData()"
:options="({
indexAxis: $q.screen.lt.md? 'y' : 'x',
plugins: {
title: {
display: true,
text: t('timesheet_approvals.chart.expenses_title'),
},
legend:{
labels:{
boxWidth: 15,
}
}
},
scales: {
x: {
stacked: true
},
y: {
suggestedMin: 0,
suggestedMax: 100,
stacked: true
}
}
})"
/>
</div>
</template>

View File

@ -0,0 +1,125 @@
<script setup lang="ts">
import type { PayPeriodOverview } from 'src/modules/timesheet-approval/models/pay-period-overview.models';
const modelApproval = defineModel<boolean>();
const { row } = defineProps<{ row: PayPeriodOverview; }>();
const emit = defineEmits<{
'clickDetails': [overview: PayPeriodOverview];
}>();
const stack_label_class = "text-weight-bold text-primary text-uppercase text-caption q-pa-none q-my-none ellipsis";
</script>
<template>
<div class="q-px-sm q-pb-sm q-mt-sm col-xs-12 col-sm-6 col-md-4 col-lg-4 col-xl-3 grid-style-transition">
<q-card class="rounded-10">
<!-- Card header with employee name and details button-->
<q-card-section horizontal class="q-py-none q-px-sm q-ma-none justify-between items-center">
<span class="col text-primary text-h5 text-weight-bolder q-pt-xs"> {{ row.employee_name }} </span>
<!-- Buttons to view detailed shifts or view employee timesheet -->
<q-btn
flat
dense
square
unelevated
class="col-auto q-pa-none q-ma-none"
color="primary"
icon="work_history"
@click="emit('clickDetails', row)"
>
<q-tooltip
anchor="top middle"
self="center middle"
class="bg-primary text-uppercase text-weight-bold"
>
{{ $t('timesheet_approvals.tooltip.button_detailed_view') }}
</q-tooltip>
</q-btn>
</q-card-section>
<q-separator size="2px" />
<!-- Main body of pay period card -->
<q-card-section class="q-py-none q-px-sm q-mt-sm q-mb-md">
<div class="row no-wrap">
<!-- left portion of pay period card -->
<div class="col column no-wrap q-px-sm">
<!-- Regular hours segment -->
<div class="column" :class="$q.screen.lt.md ? 'col' : 'col-8'">
<span :class="stack_label_class"> {{ $t('shared.shift_type.regular') }} </span>
<span class="text-weight-bolder text-h3 q-py-none"> {{ row.regular_hours }} </span>
</div>
<q-separator class="q-mx-sm" />
<!-- Other hour types segment -->
<div class="row q-px-xs">
<div class="col column no-wrap">
<span :class="stack_label_class" style="font-size: 0.65em;"> {{ $t('shared.shift_type.evening') }} </span>
<span class="text-weight-bolder text-h6 q-pa-none" style="line-height: 0.9em;"> {{ row.evening_hours }} </span>
</div>
<div class="col column no-wrap">
<span :class="stack_label_class" style="font-size: 0.65em;"> {{ $t('shared.shift_type.emergency') }} </span>
<span class="text-weight-bolder text-h6 q-pa-none" style="line-height: 0.9em;"> {{ row.emergency_hours }} </span>
</div>
<div class="col column no-wrap">
<span :class="stack_label_class" style="font-size: 0.65em;"> {{ $t('shared.shift_type.overtime') }} </span>
<span class="text-weight-bolder text-h6 q-pa-none" style="line-height: 0.9em;"> {{ row.overtime_hours }} </span>
</div>
</div>
</div>
<q-separator
vertical
class="q-mt-xs q-mb-none"
/>
<!-- Right portion of pay period card -->
<div class="col-auto column q-px-sm">
<div class="col column no-wrap">
<span :class="stack_label_class" style="font-size: 0.8em;"> {{ $t('timesheet.expense.types.EXPENSES') }} </span>
<span class="text-weight-bolder text-h6 q-pa-none" style="line-height: 0.9em;"> {{ row.expenses }} </span>
</div>
<div class="col column no-wrap">
<span :class="stack_label_class" style="font-size: 0.8em;"> {{ $t('timesheet.expense.types.MILEAGE') }} </span>
<span class="text-weight-bolder text-h6 q-pa-none" style="line-height: 0.9em;"> {{ row.mileage }} </span>
</div>
</div>
</div>
</q-card-section>
<q-separator color="primary" size="2px" />
<!-- Validate Pay Period section -->
<q-card-section
horizontal
class="justify-between items-center text-weight-bold q-px-sm"
:class="row.is_approved ? 'text-white bg-primary' : 'bg-dark'"
>
<div class="col-auto">
<span class="text-uppercase text-h6 q-ml-sm text-weight-bolder"> {{ row.total_hours }} </span>
<span class="text-uppercase text-weight-bold text-caption q-ml-xs"> total </span>
</div>
<q-checkbox
v-model="modelApproval"
dense
left-label
size="lg"
checked-icon="lock"
unchecked-icon="lock_open"
:color="row.is_approved ? 'white' : 'primary'"
:label="row.is_approved ? $t('timesheet_approvals.table.verified') : $t('timesheet_approvals.table.unverified')"
class="col-auto text-uppercase"
/>
</q-card-section>
</q-card>
</div>
</template>

View File

@ -0,0 +1,91 @@
<script
setup
lang="ts"
>
import { computed, ref } from 'vue';
import { useTimesheetStore } from 'src/stores/timesheet-store';
import OverviewListItem from 'src/modules/timesheet-approval/components/overview-list-item.vue';
import QTableFilters from 'src/modules/shared/components/q-table-filters.vue';
import PayPeriodNavigator from 'src/modules/shared/components/pay-period-navigator.vue';
import { pay_period_overview_columns, type PayPeriodOverview } from 'src/modules/timesheet-approval/models/pay-period-overview.models';
const timesheet_store = useTimesheetStore();
const filter = ref<string | number | null>('');
const employeeEmail = defineModel();
const emit = defineEmits<{
'clickedDetailsButton': [email: string];
}>();
const overview_rows = computed(() => timesheet_store.pay_period_overviews[0]?.regular_hours === -1 ?
[] :
timesheet_store.pay_period_overviews
)
const onClickedDetails = async (employee_email: string, row: PayPeriodOverview) => {
employeeEmail.value = employee_email;
emit('clickedDetailsButton', employee_email);
timesheet_store.current_pay_period_overview = row;
await timesheet_store.getPayPeriodDetailsByEmployeeEmail(employee_email);
};
</script>
<template>
<div class="q-pa-md">
<q-table
:rows="overview_rows"
:columns="pay_period_overview_columns"
row-key="email"
:filter="filter"
grid
dense
hide-pagination
color="primary"
:rows-per-page-options="[0]"
card-container-class="justify-center"
:loading="timesheet_store.is_loading"
:no-data-label="$t('shared.error.no_data_found')"
:no-results-label="$t('shared.error.no_search_results')"
:loading-label="$t('shared.label.loading')"
>
<template #top>
<div
class="full-width"
:class="$q.screen.lt.md ? 'text-center' : 'row'"
>
<PayPeriodNavigator />
<q-space />
<!-- Grid-or-List toggle goes here -->
<QTableFilters v-model="filter" />
</div>
</template>
<!-- Template for individual employee cards -->
<template #item="props: { row: PayPeriodOverview, key: string }">
<OverviewListItem
v-model="props.row.is_approved"
:row="props.row"
@click-details="overview => onClickedDetails(props.row.email, overview)"
/>
</template>
<!-- Template for custome failed-to-load state -->
<template #no-data="{ message, filter }">
<div class="full-width column items-center text-primary q-gutter-sm">
<q-icon
size="4em"
:name="filter ? 'filter_alt_off' : 'error_outline'"
/>
<span class="text-h6">
{{ message }}
</span>
</div>
</template>
</q-table>
</div>
</template>

View File

@ -0,0 +1,93 @@
<script
setup
lang="ts"
>
import { default_timesheet_approval_cvs_report_filters, type TimesheetApprovalCSVReportFilters } from 'src/modules/timesheet-approval/models/timesheet-approval-csv-report.models';
import { ref, computed } from 'vue';
const report_filter_options = ref<TimesheetApprovalCSVReportFilters>(default_timesheet_approval_cvs_report_filters);
const company_options = [
{ label: 'Targo', value: report_filter_options.value.companies.targo },
{ label: 'Solucom', value: report_filter_options.value.companies.solucom },
];
const type_options = [
{ label: 'timesheet_approvals.print_report.shifts', value: report_filter_options.value.types.shifts },
{ label: 'timesheet_approvals.print_report.expenses', value: report_filter_options.value.types.expenses },
{ label: 'shared.shift_type.holiday', value: report_filter_options.value.types.holiday },
{ label: 'shared.shift_type.vacation', value: report_filter_options.value.types.vacation },
];
const is_download_button_disabled = computed(() => {
return company_options.map(option => option.value).filter(value => value === true).length > 0 ||
type_options.map(option => option.value).filter(value => value === true).length > 0;
});
</script>
<template>
<q-btn-group
rounded
push
>
<q-btn
rounded
push
color="primary"
icon="print"
:disable="is_download_button_disabled"
/>
<q-btn-dropdown
rounded
push
color="white"
text-color="primary"
icon="checklist"
>
<q-list class="row">
<q-item class="col">
<q-item-label class="text-weight-bolder text-primary q-ma-none q-pa-none text-uppercase">
{{ $t('timesheet_approvals.print_report.company') }}
</q-item-label>
<q-item-section
row
no-wrap
>
<q-checkbox
v-for="option, index in company_options"
:key="index"
v-model="option.value"
:val="option.label"
:label="option.label"
/>
</q-item-section>
</q-item>
<q-separator
spaced
vertical
color="primary"
/>
<q-item class="col">
<q-item-section
row
no-wrap
>
<p class="text-weight-bolder text-primary q-ma-none q-pa-none text-uppercase">
{{ $t('timesheet_approvals.print_report.type') }}</p>
<q-checkbox
v-for="option, index in type_options"
:key="index"
v-model="option.value"
:val="option.label"
:label="option.label"
/>
</q-item-section>
</q-item>
</q-list>
</q-btn-dropdown>
</q-btn-group>
</template>

View File

@ -1,99 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue';
import type { Shift } from 'src/modules/timesheets/types/shift.interfaces';
const props = defineProps<{
shift: Shift;
}>();
const is_showing_time_popup = ref<boolean>(false);
const getShiftColor = (type: string): string => {
switch(type) {
case 'REGULAR': return 'secondary';
case 'EVENING': return 'warning';
case 'EMERGENCY': return 'amber-10';
case 'OVERTIME': return 'negative';
case 'VACATION': return 'purple-10';
case 'HOLIDAY': return 'purple-10';
case 'SICK': return 'grey-8';
default : return 'transparent';
}
};
</script>
<template>
<q-card-section
horizontal
class="q-pa-none text-uppercase text-center items-center cursor-pointer rounded-10"
style="line-height: 1;"
@click="is_showing_time_popup = true"
>
<!-- punch-in timestamps -->
<q-card-section class="q-pa-none col">
<q-item-label
class="text-weight-bolder q-pa-xs rounded-5"
:class="'bg-' + getShiftColor(props.shift.type) + (!$q.dark.isActive && props.shift.type === 'REGULAR' ? '' : ' text-white')"
style="font-size: 1.5em; line-height: 80% !important;"
>
{{ props.shift.start_time }}
</q-item-label>
</q-card-section>
<!-- arrows pointing to punch-out timestamps -->
<q-card-section
horizontal
class="items-center justify-center q-mx-sm col"
>
<div
v-for="icon_data, index in [
{ transform: 'transform: translateX(5px);', color: 'accent' },
{ transform: 'transform: translateX(-5px);', color: 'primary' }]"
:key="index"
>
<q-icon
v-if="props.shift.type"
name="double_arrow"
:color="icon_data.color"
size="24px"
:style="icon_data.transform"
/>
</div>
</q-card-section>
<!-- punch-out timestamps -->
<q-card-section class="q-pa-none col">
<q-item-label
class="text-weight-bolder q-pa-xs rounded-5"
:class="'bg-' + getShiftColor(props.shift.type) + (!$q.dark.isActive && props.shift.type === 'REGULAR' ? '' : ' text-white')"
style="font-size: 1.5em; line-height: 80% !important;"
>
{{ props.shift.end_time }}
</q-item-label>
</q-card-section>
<!-- comment and expenses buttons -->
<q-card-section
class="col q-pa-none text-right"
>
<!-- chat_bubble_outline or announcement -->
<q-btn
v-if="props.shift.type"
flat
dense
icon="chat_bubble_outline"
class="q-pa-none"
/>
<!-- insert_drive_file or request_quote -->
<q-btn
v-if="props.shift.type"
flat
dense
icon="attach_money"
class="q-pa-none q-mx-xs"
/>
</q-card-section>
</q-card-section>
</template>

View File

@ -1,73 +0,0 @@
<script setup lang="ts">
import TimesheetApprovalEmployeeDetailsShiftsRow from 'src/modules/timesheet-approval/components/timesheet-approval-employee-details-shifts-row.vue';
import TimesheetApprovalEmployeeDetailsShiftsRowHeader from 'src/modules/timesheet-approval/components/timesheet-approval-employee-details-shifts-row-header.vue';
import type { PayPeriod } from 'src/modules/shared/types/pay-period-interface';
import type { PayPeriodEmployeeDetails } from 'src/modules/timesheet-approval/types/timesheet-approval-pay-period-employee-details-interface';
import type { Shift } from 'src/modules/timesheets/types/shift.interfaces';
import { default_shift } from 'src/modules/timesheets/types/shift.defaults';
const props = defineProps<{
rawData: PayPeriodEmployeeDetails;
currentPayPeriod: PayPeriod;
}>();
const shifts_or_placeholder = (shifts: Shift[]): Shift[] => {
return shifts.length > 0 ? shifts : [default_shift];
};
const getDate = (shift_date: string): Date => {
return new Date(props.currentPayPeriod.pay_year.toString() + '/' + shift_date);
};
</script>
<template>
<div
v-for="week, index in props.rawData"
:key="index"
class="q-px-xs q-pt-xs rounded-5 col"
>
<q-card
v-for="day, day_index in week.shifts"
:key="day_index"
flat
bordered
class="row items-center rounded-10 q-mb-xs"
>
<q-card-section class="col-auto q-pa-xs text-white">
<div
class="bg-primary rounded-10 q-pa-xs text-center"
:style="$q.screen.lt.md ? '' : 'width: 75px;'"
>
<q-item-label
style="font-size: 0.7em;"
class="text-uppercase"
>{{ $d(getDate(day.short_date), {weekday: $q.screen.lt.md ? 'short' : 'long'}) }}</q-item-label>
<q-item-label
class="text-weight-bolder"
style="font-size: 2.5em; line-height: 90% !important;"
>{{ day.short_date.split('/')[1] }}</q-item-label>
<q-item-label
style="font-size: 0.7em;"
class="text-uppercase"
>{{ $d(getDate(day.short_date), {month: $q.screen.lt.md ? 'short' : 'long'}) }}</q-item-label>
</div>
</q-card-section>
<q-card-section class="col q-pa-none">
<TimesheetApprovalEmployeeDetailsShiftsRowHeader />
<TimesheetApprovalEmployeeDetailsShiftsRow
v-for="shift, shift_index in shifts_or_placeholder(day.shifts)"
:key="shift_index"
:shift="shift"
/>
</q-card-section>
<q-card-section class="q-pr-xs col-auto">
<q-btn
push
color="primary"
icon="more_time"
class="q-pa-sm"
/>
</q-card-section>
</q-card>
</div>
</template>

View File

@ -1,160 +0,0 @@
<script setup lang="ts">
import type { PayPeriodOverviewEmployee } from 'src/modules/timesheet-approval/types/timesheet-approval-pay-period-overview-employee-interface';
type TableColumn = {
name: string;
label: string;
value: unknown;
};
const { cols, row, initialState } = defineProps<{
cols: TableColumn[];
row: PayPeriodOverviewEmployee;
initialState: boolean;
}>();
const emit = defineEmits<{
clickDetails: [ email: string ];
updateApproval: [ value: boolean ];
}>();
</script>
<template>
<div class="q-px-sm q-pb-sm q-mt-sm col-xs-12 col-sm-6 col-md-4 col-lg-4 col-xl-3 grid-style-transition">
<q-card class="rounded-10">
<!-- Card header with employee name and details button-->
<q-card-section
horizontal
class="q-py-none q-pl-md relative"
>
<div class="text-primary text-h5 text-weight-bolder q-pt-xs overflow-hidden">
{{ row.employee_name }}
</div>
<q-space />
<!-- Buttons to view detailed shifts or view employee timesheet -->
<q-btn
flat
dense
square
unelevated
class="q-py-none q-my-xs"
color="primary"
icon="work_history"
@click="emit('clickDetails', row.email)"
>
<q-tooltip
anchor="top middle"
self="center middle"
class="bg-primary text-uppercase text-weight-bold"
>
{{ $t('timesheet_approvals.tooltip.button_detailed_view') }}
</q-tooltip>
</q-btn>
</q-card-section>
<q-separator size="2px" />
<!-- Main body of pay period card -->
<q-card-section class="q-pa-none q-mt-xs q-mb-sm">
<div class="row no-wrap">
<!-- left portion of pay period card -->
<div
class="column no-wrap"
:class="$q.screen.lt.md ? 'col' : 'col-8'"
>
<!-- Regular hours segment -->
<q-item
dense
class="column"
:class="$q.screen.lt.md ? 'col' : 'col-8'"
>
<q-item-label class="text-weight-bold text-primary q-pa-none text-uppercase text-caption">
{{ cols.find(c => c.name === 'regular_hours')?.label }}
</q-item-label>
<q-item-label class="text-weight-bolder text-h3 q-py-none">
{{ cols.find(c => c.name === 'regular_hours')?.value }}
</q-item-label>
</q-item>
<q-separator class="q-mx-sm" />
<!-- Other hour types segment -->
<div :class="$q.screen.lt.md ? 'column' : 'row no-wrap'">
<q-item
dense
class="column ellipsis "
v-for="col in cols.slice(3, 6)"
:key="col.label"
>
<q-item-label
class="text-weight-bold text-primary q-pa-none text-uppercase text-caption"
style="font-size: 0.65em;"
>
{{ col.label }}
</q-item-label>
<q-item-label class="text-weight-bolder q-pa-none text-h6 ">
{{ col.value }}
</q-item-label>
</q-item>
</div>
</div>
<q-separator
vertical
class="q-mt-xs q-mb-none"
/>
<!-- Right portion of pay period card -->
<div class="no-wrap ellipsis col">
<q-item
dense
class="column"
v-for="col in cols.slice(6, )"
:key="col.label"
>
<q-item-label
class="text-weight-bold text-primary q-pa-none text-uppercase text-caption ellipsis"
style="font-size: 0.8em;"
>
{{ col.label }}
</q-item-label>
<q-item-label class="text-weight-bolder q-pa-none text-h6 ">
{{ col.value }}
</q-item-label>
</q-item>
</div>
</div>
</q-card-section>
<q-separator
color="primary"
style="height: 2px;"
/>
<!-- Validate entire Pay Period section -->
<q-card-section
horizontal
class="q-pa-sm text-weight-bold"
:class="initialState ? 'text-white bg-primary' : 'bg-dark'"
>
<q-item-label class="text-uppercase text-h6 q-ml-sm text-weight-bolder"> {{ row.total_hours + ' h' }} </q-item-label>
<q-item-label class="text-uppercase text-weight-bold q-ml-xs"> total </q-item-label>
<q-space />
<q-checkbox
dense
left-label
size="lg"
checked-icon="lock"
unchecked-icon="lock_open"
:color="initialState ? 'white' : 'primary'" keep-color
:model-value="initialState"
@update:model-value="val => emit('updateApproval', val)"
:label="initialState ? $t('timesheet_approvals.table.verified') : $t('timesheet_approvals.table.unverified')"
class="text-uppercase"
/>
</q-card-section>
</q-card>
</div>
</template>

View File

@ -1,267 +0,0 @@
<script setup lang="ts">
/* eslint-disable */
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { date, type QTableColumn } from 'quasar';
import { useTimesheetStore } from 'src/stores/timesheet-store';
import { useTimesheetApprovalApi } from '../composables/use-timesheet-approval-api';
import PayPeriodCalendarPicker from 'src/modules/shared/components/utils/pay-period-calendar-picker.vue';
import TimesheetApprovalEmployeeOverviewListItem from './timesheet-approval-employee-overview-list-item.vue';
import TimesheetApprovalEmployeeDetails from 'src/modules/timesheet-approval/pages/timesheet-approval-employee-details.vue';
import { type PayPeriodOverviewEmployee } from '../types/timesheet-approval-pay-period-overview-employee-interface';
const { t } = useI18n();
const timesheet_store = useTimesheetStore();
const timesheet_approval_api = useTimesheetApprovalApi();
const FORWARD = 1
const BACKWARD = -1
const filter = ref<string | number | null>('');
const original_approvals = ref<Record<string, boolean>>({});
const is_showing_details = ref<boolean>(false);
const report_filter_company = ref<boolean[]>([true, true]);
const report_filter_type = ref<boolean[]>([true, true, true, true]);
const clicked_employee_name = ref<string>('');
const clicked_employee_email = ref<string>('');
const update_key = ref<number>(0);
const columns = computed((): QTableColumn<PayPeriodOverviewEmployee>[] => [
{
name: 'employee_name',
label: t('timesheet_approvals.table.full_name'),
field: 'employee_name',
sortable: true
},
{
name: 'email',
label: t('timesheet_approvals.table.email'),
field: 'email',
sortable: true,
},
{
name: 'regular_hours',
label: t('shared.shift_type.regular'),
field: 'regular_hours',
sortable: true
},
{
name: 'evening_hours',
label: t('shared.shift_type.evening'),
field: 'evening_hours'
},
{
name: 'emergency_hours',
label: t('shared.shift_type.emergency'),
field: 'emergency_hours'
},
{
name: 'overtime_hours',
label: t('shared.shift_type.overtime'),
field: 'overtime_hours'
},
{
name: 'expenses',
label: t('timesheet_approvals.table.expenses'),
field: 'expenses',
sortable: true
},
{
name: 'mileage',
label: t('timesheet_approvals.table.mileage'),
field: 'mileage',
sortable: true
}
]);
// const has_changes = computed(() => {
// return timesheet_store.pay_period_overview_employees.some(emp => {
// return emp.is_approved !== original_approvals.value[emp.email];
// });
// });
const is_not_enough_filters = computed(() => {
return report_filter_company.value.filter(val => val === true).length < 1 ||
report_filter_type.value.filter(val => val === true).length < 1;
});
const filter_types_labels = [
t('timesheet_approvals.print_report.shifts'),
t('timesheet_approvals.print_report.expenses'),
t('shared.shift_type.holiday'),
t('shared.shift_type.vacation'),
];
const is_calendar_limit = computed( () => {
return timesheet_store.current_pay_period.pay_year === 2024 &&
timesheet_store.current_pay_period.pay_period_no <= 1;
});
const getEmployeeApprovalStatusReference = (email: string): boolean => {
const approval_status = timesheet_store.pay_period_overview_employee_approval_statuses?.find( status => status.key === email);
if (approval_status) {
return approval_status.value;
}
return false;
}
const updateEmployeeApprovalStatus = (email: string, value: boolean) => {
const approval_status = timesheet_store.pay_period_overview_employee_approval_statuses?.find( status => status.key === email);
if (approval_status) {
approval_status.value = value;
}
}
const getEmployeeOverview = (email: string): PayPeriodOverviewEmployee => {
return timesheet_approval_api.getPayPeriodOverviewByEmployeeEmail(email);
}
const onDateSelected = async (date_string: string) => {
await timesheet_approval_api.getPayPeriodOverviewByDate(date_string);
};
const onClickedDetails = async (email: string, name: string) => {
clicked_employee_name.value = name;
clicked_employee_email.value = email;
is_showing_details.value = true;
await timesheet_approval_api.getTimesheetsByPayPeriodAndEmail(email);
};
const onClickPrintReport = async () => {
await timesheet_approval_api.getTimesheetApprovalCSVReport(report_filter_company.value, report_filter_type.value);
};
onMounted( async () => {
const today = date.formatDate(new Date(), 'YYYY-MM-DD');
await timesheet_approval_api.getPayPeriodOverviewByDate(today);
const approvals = timesheet_store.pay_period_overview_employees.map(emp => [emp.email, emp.is_approved]);
original_approvals.value = Object.fromEntries(approvals);
})
</script>
<template>
<q-dialog
v-model="is_showing_details"
transition-show="jump-down"
transition-hide="jump-down"
@before-show="() => update_key += 1"
full-width
:full-height="$q.screen.gt.sm"
>
<TimesheetApprovalEmployeeDetails
:is-loading="timesheet_store.is_loading"
:employee-name="clicked_employee_name"
:employee-overview="getEmployeeOverview(clicked_employee_email)"
:employee-details="timesheet_store.pay_period_employee_details"
:current-pay-period="timesheet_store.current_pay_period"
:update-key="update_key"
/>
</q-dialog>
<div class="q-pa-md">
<q-table
:rows="timesheet_store.pay_period_overview_employees"
:columns="columns"
row-key="email"
:filter="filter"
grid
dense
hide-pagination
color="primary"
:rows-per-page-options="[0]"
card-container-class="justify-center"
:loading="timesheet_store.is_loading"
:no-data-label="$t('shared.error.no_data_found')"
:no-results-label="$t('shared.error.no_search_results')"
:loading-label="$t('shared.label.loading')"
>
<!-- Top Bar that contains Date Picker, Search, Filters, Print Report, etc -->
<template #top>
<div class="full-width" :class="$q.screen.lt.md ? 'text-center q-gutter-sm' : 'row'">
<!-- Date Picker -->
<PayPeriodCalendarPicker
:is-disabled="timesheet_store.is_loading"
:is-previous-limit="is_calendar_limit"
@date-selected="onDateSelected"
@pressed-previous-button="timesheet_approval_api.getNextPayPeriodOverview(BACKWARD)"
@pressed-next-button="timesheet_approval_api.getNextPayPeriodOverview(FORWARD)"
/>
<q-space />
<q-btn-group rounded push>
<q-btn
rounded
push
color="primary"
icon="print"
:disable="is_not_enough_filters"
@click="onClickPrintReport"
/>
<q-btn-dropdown
rounded
push
color="white"
text-color="primary"
icon="checklist"
>
<q-list>
<q-item>
<q-item-section row no-wrap>
<p class="text-weight-bolder text-primary q-ma-none q-pa-none text-uppercase">{{$t('timesheet_approvals.print_report.company')}}</p>
<q-checkbox
v-for="label, index in ['Targo', 'Solucom']"
v-model="report_filter_company[index]"
:val="label"
:label=label
:key="index"
/>
</q-item-section>
</q-item>
<q-separator color="primary" class="q-mx-md"/>
<q-item>
<q-item-section row no-wrap>
<p class="text-weight-bolder text-primary q-ma-none q-pa-none text-uppercase">{{$t('timesheet_approvals.print_report.type')}}</p>
<q-checkbox
v-for="label, index in filter_types_labels"
v-model="report_filter_type[index]"
:val="label"
:label="label"
:key="index"
/>
</q-item-section>
</q-item>
</q-list>
</q-btn-dropdown>
</q-btn-group>
</div>
</template>
<!-- Template for individual employee cards -->
<template #item="props: {
cols: (QTableColumn<PayPeriodOverviewEmployee> & { value: unknown })[],
row: PayPeriodOverviewEmployee,
key: string,
}">
<TimesheetApprovalEmployeeOverviewListItem
:cols="props.cols"
:row="props.row"
:initial-state="getEmployeeApprovalStatusReference(props.key)"
@click-details="email => onClickedDetails(email, props.row.employee_name)"
@update-approval="value => updateEmployeeApprovalStatus(props.key, value)"
/>
</template>
<!-- Template for custome failed-to-load state -->
<template #no-data="{ message, filter }">
<div class="full-width column items-center text-primary q-gutter-sm">
<span class="text-h6 q-mt-xl">
{{ message }}
</span>
<q-icon
size="4em"
:name="filter ? 'filter_alt_off' : 'error_outline'"
/>
</div>
</template>
</q-table>
</div>
</template>

View File

@ -1,92 +1,40 @@
import { useTimesheetStore } from "src/stores/timesheet-store"; import { useTimesheetStore } from "src/stores/timesheet-store";
import { useAuthStore } from "src/stores/auth-store"; import { useAuthStore } from "src/stores/auth-store";
import type { PayPeriodReportFilters } from "../types/timesheet-approval-pay-period-report-interface"; import type { TimesheetApprovalCSVReportFilters } from "src/modules/timesheet-approval/models/timesheet-approval-csv-report.models";
import { default_pay_period_overview_employee, type PayPeriodOverviewEmployee } from "../types/timesheet-approval-pay-period-overview-employee-interface";
import { date } from "quasar";
export const useTimesheetApprovalApi = () => { export const useTimesheetApprovalApi = () => {
const timesheet_store = useTimesheetStore(); const timesheet_store = useTimesheetStore();
const auth_store = useAuthStore(); const auth_store = useAuthStore();
const getPayPeriodOverviewByDate = async (date_string: string) => { const getPayPeriodOverviewsByDate = async (date_string: string): Promise<void> => {
const success = await timesheet_store.getPayPeriodByDate(date_string); const success = await timesheet_store.getPayPeriodByDateOrYearAndNumber(date_string);
if (success) { if (success) {
const current_pay_period = timesheet_store.current_pay_period; await timesheet_store.getPayPeriodOverviewsBySupervisorEmail(
await timesheet_store.getTimesheetApprovalPayPeriodEmployeeOverviews(current_pay_period.pay_year, current_pay_period.pay_period_no, auth_store.user.email); timesheet_store.pay_period.pay_year,
} timesheet_store.pay_period.pay_period_no,
} auth_store.user.email
);
const getPayPeriodOverviewByEmployeeEmail = (email: string): PayPeriodOverviewEmployee => {
const employee_overview = timesheet_store.pay_period_overview_employees.find(overview => overview.email === email);
if (employee_overview !== undefined) return employee_overview;
return default_pay_period_overview_employee;
};
/* This method attempts to get the next or previous pay period.
It checks if pay period number is within a certain range, adjusts pay period and year accordingly.
It then requests the matching pay period object to set as current pay period from server.
If successful, it then requests pay period overviews from that new pay period. */
const getNextPayPeriodOverview = async (direction: number) => {
const current_pay_period = timesheet_store.current_pay_period;
let new_pay_period_no = current_pay_period.pay_period_no + direction;
let new_pay_year = current_pay_period.pay_year;
if (new_pay_period_no > 26) {
new_pay_period_no = 1;
new_pay_year += 1;
}
if (new_pay_period_no < 1) {
new_pay_period_no = 26;
new_pay_year -= 1;
}
const success = await timesheet_store.getPayPeriodByYearAndPeriodNumber(new_pay_year, new_pay_period_no);
if (success) {
await timesheet_store.getTimesheetApprovalPayPeriodEmployeeOverviews(new_pay_year, new_pay_period_no, auth_store.user.email);
} }
}; };
const getTimesheetsByPayPeriodAndEmail = async (employee_email: string) => { const getTimesheetApprovalCSVReport = async ( report_filter_company: boolean[], report_filter_type: boolean[], year?: number, period_number?: number ) => {
await timesheet_store.getTimesheetsByPayPeriodAndEmail(employee_email);
};
const getTimesheetApprovalCSVReport = async ( report_filter_company: boolean[], report_filter_type: boolean[] ) => {
const [ targo, solucom ] = report_filter_company; const [ targo, solucom ] = report_filter_company;
const [ shifts, expenses, holiday, vacation ] = report_filter_type; const [ shifts, expenses, holiday, vacation ] = report_filter_type;
const options = { const options = {
company: { targo, solucom }, types: { shifts, expenses, holiday, vacation },
types: { shifts, expenses, holiday, vacation } companies: { targo, solucom },
} as PayPeriodReportFilters; } as TimesheetApprovalCSVReportFilters;
await timesheet_store.getTimesheetApprovalCSVReport(options); await timesheet_store.getPayPeriodReportByYearAndPeriodNumber(
}; year ?? timesheet_store.pay_period.pay_year,
period_number ?? timesheet_store.pay_period.pay_period_no,
const getCurrentPayPerdioOverview = async (): Promise<void> => { options
const today = date.formatDate(new Date(), 'YYYY-MM-DD');
const success = await timesheet_store.getPayPeriodByDate(today);
if(!success) return;
const { pay_year, pay_period_no } = timesheet_store.current_pay_period;
await timesheet_store.getTimesheetApprovalPayPeriodEmployeeOverviews(
pay_year,
pay_period_no,
auth_store.user.email
); );
await timesheet_store.getTimesheetsByPayPeriodAndEmail(auth_store.user.email);
}; };
return { return {
getPayPeriodOverviewByDate, getPayPeriodOverviewsByDate,
getNextPayPeriodOverview,
getPayPeriodOverviewByEmployeeEmail,
getTimesheetsByPayPeriodAndEmail,
getTimesheetApprovalCSVReport, getTimesheetApprovalCSVReport,
getCurrentPayPerdioOverview
} }
}; };

View File

@ -0,0 +1,43 @@
export interface shiftColor {
type_label: string;
background_color: string;
font_color: string;
}
export const shift_type_legend: shiftColor[] = [
{
type_label: 'shared.shift_type.regular',
background_color: 'blue-grey-4',
font_color: 'blue-grey-8',
},
{
type_label: 'shared.shift_type.evening',
background_color: 'warning',
font_color: 'blue-grey-2',
},
{
type_label: 'shared.shift_type.emergency',
background_color: 'amber-10',
font_color: 'blue-grey-2',
},
{
type_label: 'shared.shift_type.overtime',
background_color: 'negative',
font_color: 'blue-grey-2',
},
{
type_label: 'shared.shift_type.vacation',
background_color: 'purple-10',
font_color: 'blue-grey-2',
},
{
type_label: 'shared.shift_type.holiday',
background_color: 'purple-8',
font_color: 'blue-grey-2',
},
{
type_label: 'shared.shift_type.sick',
background_color: 'grey-8',
font_color: 'blue-grey-2',
},
]

View File

@ -0,0 +1,92 @@
export interface PayPeriodOverview {
email: string;
employee_name: string;
regular_hours: number;
evening_hours: number;
emergency_hours: number;
overtime_hours: number;
total_hours: number;
expenses: number;
mileage: number;
is_approved: boolean;
};
export interface PayPeriodOverviewResponse {
pay_period_no: number;
pay_year: number;
period_start: string;
period_end: string;
payday: string;
label: string;
employees_overview: PayPeriodOverview[];
}
export const default_pay_period_overview: PayPeriodOverview = {
email: '',
employee_name: '',
regular_hours: -1,
evening_hours: -1,
emergency_hours: -1,
overtime_hours: -1,
total_hours: -1,
expenses: -1,
mileage: -1,
is_approved: false
}
export const pay_period_overview_columns = [
{
name: 'employee_name',
label: 'timesheet_approvals.table.full_name',
field: 'employee_name',
sortable: true
},
{
name: 'email',
label: 'timesheet_approvals.table.email',
field: 'email',
sortable: true,
},
{
name: 'regular_hours',
label: 'shared.shift_type.regular',
field: 'regular_hours',
sortable: true,
},
{
name: 'evening_hours',
label: 'shared.shift_type.evening',
field: 'evening_hours',
sortable: true,
},
{
name: 'emergency_hours',
label: 'shared.shift_type.emergency',
field: 'emergency_hours',
sortable: true,
},
{
name: 'overtime_hours',
label: 'shared.shift_type.overtime',
field: 'overtime_hours',
sortable: true,
},
{
name: 'expenses',
label: 'timesheet_approvals.table.expenses',
field: 'expenses',
sortable: true,
},
{
name: 'mileage',
label: 'timesheet_approvals.table.mileage',
field: 'mileage',
sortable: true,
},
{
name: 'is_approved',
label: 'timesheet_approvals.table.is_approved',
field: 'is_approved',
sortable: true,
}
];

View File

@ -0,0 +1,25 @@
export interface TimesheetApprovalCSVReportFilters {
types: {
shifts: boolean;
expenses: boolean;
holiday: boolean;
vacation: boolean;
};
companies: {
targo: boolean;
solucom: boolean;
};
}
export const default_timesheet_approval_cvs_report_filters: TimesheetApprovalCSVReportFilters = {
types: {
shifts: true,
expenses: true,
holiday: true,
vacation: true,
},
companies: {
targo: true,
solucom: true,
},
};

View File

@ -1,164 +0,0 @@
<script setup lang="ts">
/*eslint-disable*/
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import TimesheetApprovalEmployeeDetailsShifts from 'src/modules/timesheet-approval/components/timesheet-approval-employee-details-shifts.vue';
import TimesheetApprovalEmployeeDetailsHoursWorkedChart from 'src/modules/timesheet-approval/components/graphs/timesheet-approval-employee-details-hours-worked-chart.vue';
import TimesheetApprovalEmployeeDetailsShiftTypesChart from 'src/modules/timesheet-approval/components/graphs/timesheet-approval-employee-details-shift-types-chart.vue';
import TimesheetApprovalEmployeeExpensesChart from 'src/modules/timesheet-approval/components/graphs/timesheet-approval-employee-expenses-chart.vue';
import type { PayPeriodOverviewEmployee } from 'src/modules/timesheet-approval/types/timesheet-approval-pay-period-overview-employee-interface';
import type { PayPeriodEmployeeDetails } from '../types/timesheet-approval-pay-period-employee-details-interface';
import type { PayPeriod } from 'src/modules/shared/types/pay-period-interface';
const props = defineProps<{
isLoading: boolean;
employeeName: string;
employeeOverview: PayPeriodOverviewEmployee;
employeeDetails: PayPeriodEmployeeDetails;
currentPayPeriod: PayPeriod;
updateKey: number;
}>();
const { t } = useI18n();
const is_showing_graph = ref<boolean>(true);
type shiftColor = {
type: string;
color: string;
text_color?: string;
}
const shift_type_legend: shiftColor[] = [
{
type: t('shared.shift_type.regular'),
color: 'secondary',
text_color: '',
},
{
type: t('shared.shift_type.evening'),
color: 'warning',
},
{
type: t('shared.shift_type.emergency'),
color: 'amber-10',
},
{
type: t('shared.shift_type.overtime'),
color: 'negative',
},
{
type: t('shared.shift_type.vacation'),
color: 'purple-10',
},
{
type: t('shared.shift_type.holiday'),
color: 'purple-8',
},
{
type: t('shared.shift_type.sick'),
color: 'grey-8',
},
]
</script>
<template>
<q-card
class="q-pa-sm shadow-12 rounded-15 column no-wrap relative"
:style="$q.screen.lt.md ? '' : 'width: 60vw !important; height: 70vh !important;' "
>
<!-- loader -->
<q-card-section
v-if="props.isLoading"
class="absolute-center text-center"
>
<q-spinner
color="primary"
size="5em"
:thickness="10"
/>
<div class="text-primary text-h6 text-weight-bold">
{{ $t('shared.loading') }}
</div>
</q-card-section>
<!-- employee name -->
<q-card-section
v-if="!props.isLoading"
class="text-h5 text-weight-bolder text-center text-primary q-pa-none text-uppercase col-auto"
>
{{ props.employeeName }}
<q-separator spaced size="2px" />
<q-card-actions align="center" class="q-pa-none">
<q-card flat class="bg-secondary rounded-5 q-pa-xs">
<q-btn-toggle
color="white"
text-color="primary"
toggle-color="primary"
v-model="is_showing_graph"
:options="[
{icon: 'bar_chart', value: true},
{icon: 'edit', value: false},
]"
/>
</q-card>
</q-card-actions>
</q-card-section>
<!-- employee timesheet details edit -->
<q-card-section
v-if="!props.isLoading && !is_showing_graph"
class="q-pa-none"
>
<!-- shift type color legend -->
<q-card-section class="q-py-xs q-px-none text-center q-my-s">
<q-badge
v-for="shift_type in shift_type_legend"
:color="shift_type.color"
:label="shift_type.type"
:text-color="shift_type.text_color || 'white'"
class="q-px-md q-py-xs q-mx-xs q-my-none text-uppercase text-weight-bolder justify-center"
style="width: 120px; font-size: 0.8em;"
/>
</q-card-section>
<!-- list of shifts, broken down into weekly columns -->
<q-card-section
:horizontal="$q.screen.gt.sm"
class="q-pa-none bg-secondary rounded-10"
>
<TimesheetApprovalEmployeeDetailsShifts
:raw-data="props.employeeDetails"
:current-pay-period="props.currentPayPeriod"
/>
</q-card-section>
</q-card-section>
<!-- employee timesheet details with graphs -->
<q-card-section v-if="!props.isLoading && is_showing_graph" class="q-pa-md col column full-width no-wrap">
<q-card-section :horizontal="!$q.screen.lt.md" class="q-pa-none col no-wrap" style="min-height: 300px;">
<TimesheetApprovalEmployeeDetailsHoursWorkedChart
:raw-data="props.employeeDetails"
class="col-7"
/>
<q-separator vertical spaced />
<div class="column col justify-center no-wrap q-pa-none">
<TimesheetApprovalEmployeeDetailsShiftTypesChart
:raw-data="props.employeeOverview"
class="col-5"
/>
<q-separator :vertical="$q.screen.lt.md" spaced />
<TimesheetApprovalEmployeeExpensesChart
:raw-data="props.employeeDetails"
class="col"
/>
</div>
</q-card-section>
</q-card-section>
</q-card>
</template>

View File

@ -1,46 +0,0 @@
<script setup lang="ts">
import TimesheetApprovalEmployeeOverviewList from '../components/timesheet-approval-employee-overview-list.vue';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useTimesheetStore } from 'src/stores/timesheet-store';
const { d } = useI18n();
const timesheet_store = useTimesheetStore();
const pay_period_label = computed(() => {
const dates = timesheet_store.current_pay_period.label.split('.');
if ( dates.length < 2 ) {
return { start_date: '—', end_date: '—' }
}
const start_date = d(new Date(dates[0] as string), { day: 'numeric', month: 'long', year: 'numeric', });
const end_date = d(new Date(dates[1] as string), { day: 'numeric', month: 'long', year: 'numeric', });
return { start_date, end_date };
});
</script>
<template>
<q-page
padding
class="q-pa-md bg-secondary "
>
<div class="column q-mt-lg text-uppercase text-center text-weight-bolder text-h4">
{{ $t('timesheet_approvals.page_title') }}
<div class="col row items-center justify-center full-width q-py-none q-my-none">
<div class="text-primary text-weight-bold text-h6">
{{ pay_period_label.start_date }}
</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">
{{ pay_period_label.end_date }}
</div>
</div>
</div>
<TimesheetApprovalEmployeeOverviewList />
</q-page>
</template>

View File

@ -1,34 +0,0 @@
import { api } from "src/boot/axios";
import type { PayPeriodOverview } from "../types/timesheet-approval-pay-period-overview-interface";
import type { PayPeriod } from "src/modules/shared/types/pay-period-interface";
import type { PayPeriodEmployeeDetails } from "../types/timesheet-approval-pay-period-employee-details-interface";
import type { PayPeriodReportFilters } from "../types/timesheet-approval-pay-period-report-interface";
export const timesheetApprovalService = {
getPayPeriodByDate: async (date_string: string): Promise<PayPeriod> => {
const response = await api.get(`pay-periods/date/${date_string}`);
return response.data;
},
getPayPeriodByYearAndPeriodNumber: async (year: number, period_number: number): Promise<PayPeriod> => {
const response = await api.get(`pay-periods/${year}/${period_number}`);
return response.data;
},
getPayPeriodEmployeeOverviews: async (year: number, period_number: number, supervisor_email: string): Promise<PayPeriodOverview> => {
// TODO: REMOVE MOCK DATA PEFORE PUSHING TO PROD
const response = await api.get(`pay-periods/${year}/${period_number}/${supervisor_email}`);
return response.data;
},
getTimesheetsByPayPeriodAndEmail: async (year: number, period_no: number, email: string): Promise<PayPeriodEmployeeDetails> => {
const response = await api.get('timesheets', { params: { year, period_no, email, }});
return response.data;
},
getTimesheetApprovalCSVReport: async (year: number, period_number: number, report_filters?: PayPeriodReportFilters) => {
const response = await api.get(`csv/${year}/${period_number}`, { params: { report_filters, }});
return response.data;
},
};

View File

@ -0,0 +1,15 @@
import { api } from "src/boot/axios";
import type { TimesheetApprovalCSVReportFilters } from "src/modules/timesheet-approval/models/timesheet-approval-csv-report.models";
import type { PayPeriodOverviewResponse } from "src/modules/timesheet-approval/models/pay-period-overview.models";
export const timesheetApprovalService = {
getPayPeriodOverviewsBySupervisorEmail: async (year: number, period_number: number, supervisor_email: string): Promise<PayPeriodOverviewResponse> => {
const response = await api.get(`pay-periods/${year}/${period_number}/${supervisor_email}`);
return response.data;
},
getPayPeriodReportByYearAndPeriodNumber: async (year: number, period_number: number, report_filters?: TimesheetApprovalCSVReportFilters) => {
const response = await api.get(`csv/${year}/${period_number}`, { params: { report_filters, }});
return response.data;
},
};

View File

@ -1,287 +0,0 @@
// import type { PayPeriod } from "../shared/types/pay-period-interface";
// import type { PayPeriodEmployeeOverview } from "./types/timesheet-approval-pay-period-employee-overview-interface"
// export const mock_pay_period_employee_overviews: PayPeriodEmployeeOverview[] = [
// {
// "email": 'EMP-001',
// "employee_name": 'Alice Johnson',
// "regular_hours": 75,
// "evening_hours": 12,
// "emergency_hours": 3,
// "overtime_hours": 5,
// "expenses": 120.50,
// "mileage": 45,
// "is_approved": false
// },
// {
// "email": 'EMP-002',
// "employee_name": 'Brian Smith',
// "regular_hours": 80,
// "evening_hours": 8,
// "emergency_hours": 0,
// "overtime_hours": 2,
// "expenses": 75.00,
// "mileage": 12,
// "is_approved": true
// },
// {
// "email": 'EMP-003',
// "employee_name": 'Chloe Ramirez',
// "regular_hours": 68,
// "evening_hours": 15,
// "emergency_hours": 1,
// "overtime_hours": 0,
// "expenses": 200.00,
// "mileage": 88,
// "is_approved": false
// },
// {
// "email": 'EMP-004',
// "employee_name": 'David Lee',
// "regular_hours": 82,
// "evening_hours": 5,
// "emergency_hours": 4,
// "overtime_hours": 6,
// "expenses": 50.75,
// "mileage": 20,
// "is_approved": true
// },
// {
// "email": 'EMP-005',
// "employee_name": 'Emily Carter',
// "regular_hours": 78,
// "evening_hours": 10,
// "emergency_hours": 2,
// "overtime_hours": 3,
// "expenses": 95.25,
// "mileage": 60,
// "is_approved": false
// },
// {
// "email": 'EMP-006',
// "employee_name": 'Maxime Murray Gendron',
// "regular_hours": 80,
// "evening_hours": 0,
// "emergency_hours": 0,
// "overtime_hours": 0,
// "expenses": 0,
// "mileage": 0,
// "is_approved": false
// },
// {
// "email": 'EMP-007',
// "employee_name": 'Marc-André Henrico',
// "regular_hours": 80,
// "evening_hours": 0,
// "emergency_hours": 0,
// "overtime_hours": 0,
// "expenses": 0,
// "mileage": 0,
// "is_approved": false
// },
// {
// "email": 'EMP-008',
// "employee_name": 'Jessy Sharock',
// "regular_hours": 80,
// "evening_hours": 0,
// "emergency_hours": 0,
// "overtime_hours": 0,
// "expenses": 0,
// "mileage": 0,
// "is_approved": false
// },
// {
// "email": 'EMP-009',
// "employee_name": 'David Richer',
// "regular_hours": 80,
// "evening_hours": 0,
// "emergency_hours": 0,
// "overtime_hours": 0,
// "expenses": 0,
// "mileage": 0,
// "is_approved": false
// },
// {
// "email": 'EMP-010',
// "employee_name": 'Nicolas Drolet',
// "regular_hours": 80,
// "evening_hours": 0,
// "emergency_hours": 0,
// "overtime_hours": 0,
// "expenses": 0,
// "mileage": 0,
// "is_approved": false
// },
// {
// "email": 'EMP-011',
// "employee_name": 'Frederick Pruneau',
// "regular_hours": 16,
// "evening_hours": 0,
// "emergency_hours": 0,
// "overtime_hours": 0,
// "expenses": 0,
// "mileage": 0,
// "is_approved": false
// },
// {
// "email": 'EMP-012',
// "employee_name": 'Matthieu Haineault Gervais',
// "regular_hours": 80,
// "evening_hours": 0,
// "emergency_hours": 0,
// "overtime_hours": 0,
// "expenses": 0,
// "mileage": 0,
// "is_approved": false
// },
// {
// "email": 'EMP-013',
// "employee_name": 'Robinson Viaud',
// "regular_hours": 80,
// "evening_hours": 0,
// "emergency_hours": 0,
// "overtime_hours": 0,
// "expenses": 0,
// "mileage": 0,
// "is_approved": false
// },
// {
// "email": 'EMP-014',
// "employee_name": 'Geneviève Bourdon',
// "regular_hours": 80,
// "evening_hours": 0,
// "emergency_hours": 0,
// "overtime_hours": 0,
// "expenses": 0,
// "mileage": 0,
// "is_approved": false
// },
// {
// "email": 'EMP-015',
// "employee_name": 'Frédérique Soulard',
// "regular_hours": 80,
// "evening_hours": 0,
// "emergency_hours": 0,
// "overtime_hours": 0,
// "expenses": 0,
// "mileage": 0,
// "is_approved": false
// },
// {
// "email": 'EMP-016',
// "employee_name": 'Patrick Doucet',
// "regular_hours": 80,
// "evening_hours": 0,
// "emergency_hours": 0,
// "overtime_hours": 0,
// "expenses": 0,
// "mileage": 0,
// "is_approved": false
// },
// {
// "email": 'EMP-017',
// "employee_name": 'Dahlia Tremblay',
// "regular_hours": 80,
// "evening_hours": 0,
// "emergency_hours": 0,
// "overtime_hours": 0,
// "expenses": 0,
// "mileage": 0,
// "is_approved": false
// },
// {
// "email": 'EMP-018',
// "employee_name": 'Louis Morneau',
// "regular_hours": 80,
// "evening_hours": 0,
// "emergency_hours": 0,
// "overtime_hours": 0,
// "expenses": 0,
// "mileage": 0,
// "is_approved": false
// },
// {
// "email": 'EMP-019',
// "employee_name": 'Michel Blais',
// "regular_hours": 80,
// "evening_hours": 0,
// "emergency_hours": 0,
// "overtime_hours": 0,
// "expenses": 0,
// "mileage": 0,
// "is_approved": false
// }
// ];
// export const mock_pay_periods: PayPeriod[] = [
// {
// "period_number": 15,
// "start_date": "2025-07-27",
// "end_date": "2025-08-09",
// "year": 2025,
// "label": "2025-07-27 → 2025-08-09"
// },
// {
// "period_number": 14,
// "start_date": "2025-07-13",
// "end_date": "2025-07-26",
// "year": 2025,
// "label": "2025-07-13 → 2025-07-26"
// },
// {
// "period_number": 13,
// "start_date": "2025-06-29",
// "end_date": "2025-07-12",
// "year": 2025,
// "label": "2025-06-29 → 2025-07-12"
// },
// {
// "period_number": 12,
// "start_date": "2025-06-15",
// "end_date": "2025-06-28",
// "year": 2025,
// "label": "2025-06-15 → 2025-06-28"
// },
// {
// "period_number": 11,
// "start_date": "2025-06-01",
// "end_date": "2025-06-14",
// "year": 2025,
// "label": "2025-06-01 → 2025-06-14"
// },
// {
// "period_number": 10,
// "start_date": "2025-05-18",
// "end_date": "2025-05-31",
// "year": 2025,
// "label": "2025-05-18 → 2025-05-31"
// },
// {
// "period_number": 9,
// "start_date": "2025-05-04",
// "end_date": "2025-05-17",
// "year": 2025,
// "label": "2025-05-04 → 2025-05-17"
// },
// {
// "period_number": 8,
// "start_date": "2025-04-20",
// "end_date": "2025-05-03",
// "year": 2025,
// "label": "2025-04-20 → 2025-05-03"
// },
// {
// "period_number": 7,
// "start_date": "2025-04-06",
// "end_date": "2025-04-19",
// "year": 2025,
// "label": "2025-04-06 → 2025-04-19"
// },
// {
// "period_number": 6,
// "start_date": "2025-03-23",
// "end_date": "2025-04-05",
// "year": 2025,
// "label": "2025-03-23 → 2025-04-05"
// }
// ]

View File

@ -1,13 +0,0 @@
import { defaultTimesheetDetailsWeek } from "src/modules/timesheets/types/timesheet.defaults";
import type { TimesheetDetailsWeek } from "src/modules/timesheets/types/timesheet.interfaces";
export interface PayPeriodEmployeeDetails {
week1: TimesheetDetailsWeek;
week2: TimesheetDetailsWeek;
};
export const default_pay_period_employee_details = {
week1: defaultTimesheetDetailsWeek(),
week2: defaultTimesheetDetailsWeek(),
}

View File

@ -1,25 +0,0 @@
export interface PayPeriodOverviewEmployee {
email: string;
employee_name: string;
regular_hours: number;
evening_hours: number;
emergency_hours: number;
overtime_hours: number;
total_hours: number;
expenses: number;
mileage: number;
is_approved: boolean;
};
export const default_pay_period_overview_employee: PayPeriodOverviewEmployee = {
email: '',
employee_name: '',
regular_hours: -1,
evening_hours: -1,
emergency_hours: -1,
overtime_hours: -1,
total_hours: -1,
expenses: -1,
mileage: -1,
is_approved: false
}

View File

@ -1,11 +0,0 @@
import type { PayPeriodOverviewEmployee } from "./timesheet-approval-pay-period-overview-employee-interface";
export interface PayPeriodOverview {
pay_period_no: number;
pay_year: number;
payday: string;
period_start: string;
period_end: string;
label: string;
employees_overview: PayPeriodOverviewEmployee[];
};

View File

@ -1,16 +0,0 @@
// export interface PayPeriodReport {
// }
export interface PayPeriodReportFilters {
company: {
targo: boolean;
solucom: boolean;
};
types: {
shifts: boolean;
expenses: boolean;
holiday: boolean;
vacation: boolean;
};
}

View File

@ -1,64 +1,54 @@
<script setup lang="ts"> <script
import { ref } from 'vue'; setup
import type { QForm } from 'quasar'; lang="ts"
import type { TimesheetExpense } from '../../types/expense.interfaces'; >
import type { ExpenseType } from '../../types/expense.types'; import { inject, ref } from 'vue';
/* eslint-disable */ import { useI18n } from 'vue-i18n';
import { useExpensesStore } from 'src/stores/expense-store';
import { default_expense, EXPENSE_TYPE, TYPES_WITH_AMOUNT_ONLY } from 'src/modules/timesheets/models/expense.models';
import { makeExpenseRules } from 'src/modules/timesheets/utils/expense.util';
import { useExpensesApi } from 'src/modules/timesheets/composables/api/use-expense-api';
//---------------- v-models ------------------ const { t } = useI18n();
const draft = defineModel<Partial<TimesheetExpense>>('draft');
const files = defineModel<File[] | null>('files');
const datePickerOpen = defineModel<boolean | null>('datePickerOpen', {default: false });
//------------------ Props ------------------ const expenses_store = useExpensesStore();
const {setType} = defineProps<{ const expenses_api = useExpensesApi();
type_options: { label: string; value: ExpenseType }[]; const files = defineModel<File[] | null>('files');
show_amount: boolean; const is_navigator_open = ref(false);
is_readonly: boolean;
rules: {
typeRequired: (val: unknown) => true | string;
amountRequired: (val: unknown) => true | string;
mileageRequired: (val: unknown) => true | string;
commentRequired: (val: unknown) => true | string;
commentTooLong: (val: unknown) => true | string;
};
comment_max_length: number;
setType: (val: ExpenseType) => void;
}>();
//------------------ Emits ------------------ const COMMENT_MAX_LENGTH = 280;
defineEmits<{ const employee_email = inject<string>('employeeEmail');
'submit': [void]; const rules = makeExpenseRules(t);
}>();
//------------------ Exposes ------------------ const cancelUpdateMode = () => {
const inner_form = ref<QForm | null>(null); expenses_store.current_expense = default_expense;
defineExpose({ expenses_store.initial_expense = default_expense;
validate: async ( force = true ) => (await inner_form.value?.validate(force)) === true, expenses_store.mode = 'create';
}); }
//------------------ Handlers ------------------
const onTypeChange = (val: ExpenseType) => {
setType(val);
};
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> </script>
<template> <template>
<q-form <q-form
ref="inner_form" flat
flat v-if="!expenses_store.pay_period_expenses.is_approved"
v-if="!is_readonly" @submit.prevent="requestExpenseCreationOrUpdate"
@submit.prevent="$emit('submit')"
> >
<div class="text-subtitle2 q-py-sm"> <div class="text-subtitle2 q-py-sm">
{{ $t('timesheet.expense.add_expense')}} {{ $t('timesheet.expense.add_expense') }}
</div> </div>
<div class="row justify-between"> <div
class="row justify-between rounded-5"
:class="expenses_store.mode === 'update' ? 'bg-accent' : ''"
>
<!-- date selection input --> <!-- date selection input -->
<q-input <q-input
v-model="draft!.date" v-model="expenses_store.current_expense.date"
dense dense
filled filled
readonly readonly
@ -67,28 +57,28 @@ const onTypeChange = (val: ExpenseType) => {
color="primary" color="primary"
:label="$t('timesheet.expense.date')" :label="$t('timesheet.expense.date')"
> >
<template #before> <template #before>
<q-btn <q-btn
push push
dense dense
icon="event" icon="event"
color="primary" color="primary"
@click="datePickerOpen = true" @click="is_navigator_open = true"
/> />
<q-dialog v-model="datePickerOpen"> <q-dialog v-model="is_navigator_open">
<q-date <q-date
v-model="draft!.date" v-model="expenses_store.current_expense.date"
@update:model-value="datePickerOpen = false" @update:model-value="is_navigator_open = false"
mask="YYYY-MM-DD" mask="YYYY-MM-DD"
/> />
</q-dialog> </q-dialog>
</template> </template>
</q-input> </q-input>
<!-- expenses type selection --> <!-- expenses type selection -->
<q-select <q-select
v-model="draft!.type" v-model="expenses_store.current_expense.type"
:options="type_options" :options="EXPENSE_TYPE"
filled filled
dense dense
class="col q-px-xs" class="col q-px-xs"
@ -96,15 +86,15 @@ const onTypeChange = (val: ExpenseType) => {
emit-value emit-value
map-options map-options
:label="$t('timesheet.expense.type')" :label="$t('timesheet.expense.type')"
:rules="[ rules.typeRequired ]" :rules="[rules.typeRequired]"
@update:model-value="val => setType(val as ExpenseType)" :option-label="label => $t(label)"
/> />
<!-- amount input --> <!-- amount input -->
<template v-if="show_amount"> <template v-if="TYPES_WITH_AMOUNT_ONLY.includes(expenses_store.current_expense.type)">
<q-input <q-input
key="amount" key="amount"
v-model.number="draft!.amount" v-model.number="expenses_store.current_expense.amount"
filled filled
input-class="text-right" input-class="text-right"
dense dense
@ -115,7 +105,7 @@ const onTypeChange = (val: ExpenseType) => {
:label="$t('timesheet.expense.amount')" :label="$t('timesheet.expense.amount')"
suffix="$" suffix="$"
lazy-rules="ondemand" lazy-rules="ondemand"
:rules="[ rules.amountRequired ]" :rules="[rules.amountRequired]"
/> />
</template> </template>
@ -123,7 +113,7 @@ const onTypeChange = (val: ExpenseType) => {
<template v-else> <template v-else>
<q-input <q-input
key="mileage" key="mileage"
v-model.number="draft!.mileage" v-model.number="expenses_store.current_expense.mileage"
filled filled
input-class="text-right" input-class="text-right"
dense dense
@ -134,13 +124,13 @@ const onTypeChange = (val: ExpenseType) => {
:label="$t('timesheet.expense.mileage')" :label="$t('timesheet.expense.mileage')"
suffix="km" suffix="km"
lazy-rules="ondemand" lazy-rules="ondemand"
:rules="[ rules.mileageRequired ]" :rules="[rules.mileageRequired]"
/> />
</template> </template>
<!-- employee comment input --> <!-- employee comment input -->
<q-input <q-input
v-model="draft!.comment" v-model="expenses_store.current_expense.comment"
filled filled
color="primary" color="primary"
type="text" type="text"
@ -149,9 +139,9 @@ const onTypeChange = (val: ExpenseType) => {
stack-label stack-label
clearable clearable
:counter="true" :counter="true"
:maxlength="comment_max_length" :maxlength="COMMENT_MAX_LENGTH"
lazy-rules="ondemand" lazy-rules="ondemand"
:rules="[ rules.commentRequired, rules.commentTooLong ]" :rules="[rules.commentRequired]"
> >
<template #label> <template #label>
<span class="text-weight-bold "> <span class="text-weight-bold ">
@ -177,13 +167,21 @@ const onTypeChange = (val: ExpenseType) => {
name="attach_file" name="attach_file"
size="sm" size="sm"
color="primary" color="primary"
/> />
</template> </template>
</q-file> </q-file>
<!-- add btn section --> <!-- add btn section -->
<div> <div>
<q-btn
v-if="expenses_store.mode === 'update'"
flat
dense
size="sm"
class="q-mt-sm q-ml-sm"
@click="cancelUpdateMode"
/>
<q-btn <q-btn
push push
dense dense
@ -192,7 +190,7 @@ const onTypeChange = (val: ExpenseType) => {
size="sm" size="sm"
class="q-mt-sm q-ml-sm" class="q-mt-sm q-ml-sm"
type="submit" type="submit"
/> />
</div> </div>
</div> </div>
</q-form> </q-form>

View File

@ -0,0 +1,34 @@
<script
setup
lang="ts"
>
import { useExpensesStore } from 'src/stores/expense-store';
const expense_store = useExpensesStore();
</script>
<template>
<q-item class="row justify-between">
<q-item-label
header
class="text-h6 col-auto"
>
{{ $t('timesheet.expense.title') }}
</q-item-label>
<q-item-section class="items-center col-auto">
<q-badge
lines="1"
class="q-pa-sm q-px-md"
:label="$t('timesheet.expense.total_amount') + ': ' + expense_store.pay_period_expenses_totals.amount.toFixed(2)"
/>
<q-separator spaced />
<q-badge
lines="2"
class="q-pa-sm q-px-md"
:label="$t('timesheet.expense.total_mileage') + ': ' + expense_store.pay_period_expenses_totals.mileage.toFixed(1)"
/>
</q-item-section>
</q-item>
</template>

View File

@ -0,0 +1,151 @@
<script
setup
lang="ts"
>
import { unwrapAndClone } from 'src/utils/unwrap-and-clone';
import { expenseTypeIcon } from 'src/modules/timesheets/utils/expense.util';
import { useExpensesStore } from 'src/stores/expense-store';
import { useTimesheetStore } from 'src/stores/timesheet-store';
import { useExpensesApi } from 'src/modules/timesheets/composables/api/use-expense-api';
import { default_expense, type Expense } from 'src/modules/timesheets/models/expense.models';
import { computed, inject } from 'vue';
const timesheet_store = useTimesheetStore();
const expenses_store = useExpensesStore();
const expenses_api = useExpensesApi();
const expenses_list = computed(() => timesheet_store.pay_period_details.weeks.flatMap(week =>
Object.values(week.expenses).flatMap(day => day.expenses)));
const employee_email = inject('employeeEmail', '');
const setExpenseToModify = (expense: Expense) => {
expenses_store.mode = 'update';
expenses_store.current_expense = expense;
expenses_store.initial_expense = unwrapAndClone(expense);
};
const requestExpenseDeletion = async (expense: Expense) => {
expenses_store.mode = 'delete';
expenses_store.initial_expense = expense;
expenses_store.current_expense = default_expense;
await expenses_api.deleteExpenseByEmployeeEmail(employee_email, expenses_store.initial_expense.date);
}
</script>
<template>
<!-- liste des dépenses pré existantes -->
<q-list
padding
class="rounded-borders"
>
<q-item-label
v-if="expenses_store.pay_period_expenses.expenses.length === 0"
class="text-italic q-px-sm"
>
{{ $t('timesheet.expense.empty_list') }}
</q-item-label>
<q-item
style="border: solid 1px lightgrey; border-radius: 7px;"
v-for="(expense, index) in expenses_list"
:key="index"
class="q-my-xs shadow-1"
:class="expenses_store.mode === 'update' ? 'bg-accent' : ''"
>
<!-- avatar type icon section -->
<q-item-section avatar>
<q-icon
:name="expenseTypeIcon(expense.type)"
color="primary"
/>
</q-item-section>
<!-- amount or mileage section -->
<q-item-section top>
<q-item-label v-if="String(expense.type).trim().toUpperCase() === 'MILEAGE'">
<template v-if="typeof expense.mileage === 'number'">
{{ expense.mileage?.toFixed(1) }} km
</template>
<template v-else>
{{ expense.amount.toFixed(2) }} $
</template>
</q-item-label>
<q-item-label v-else>
{{ expense.amount.toFixed(2) }} $
</q-item-label>
<!-- date label -->
<q-item-label
caption
lines="2"
>
{{ $d(new Date(expense.date), { year: 'numeric', month: 'short', day: 'numeric', weekday: 'short' })
}}
</q-item-label>
</q-item-section>
<!-- attachment file icon -->
<q-item-section side>
<q-btn
push
dense
size="md"
color="primary"
class="q-mx-lg"
icon="attach_file"
/>
</q-item-section>
<!-- comment section -->
<q-item-section top>
<q-item-label lines="1">
{{ $t('timesheet.expense.employee_comment') }}
</q-item-label>
<q-item-label
caption
lines="2"
>
{{ expense.comment }}
</q-item-label>
</q-item-section>
<!-- supervisor comment section -->
<q-item-section top>
<q-item-label lines="1">
{{ $t('timesheet.expense.supervisor_comment') }}
</q-item-label>
<q-item-label
v-if="expense.supervisor_comment"
caption
lines="2"
>
{{ expense.supervisor_comment }}
</q-item-label>
</q-item-section>
<q-item-section
v-if="!expenses_store.pay_period_expenses.is_approved && !expense.is_approved"
side
>
<q-btn
push
dense
size="xs"
color="primary"
icon="edit"
@click="setExpenseToModify(expense)"
/>
<q-btn
push
dense
size="xs"
color="negative"
icon="close"
@click="requestExpenseDeletion(expense)"
/>
</q-item-section>
</q-item>
</q-list>
</template>

View File

@ -1,26 +1,29 @@
<script setup lang="ts"> <script
import { ref } from 'vue'; setup
/* eslint-disable */ lang="ts"
const props = defineProps<{ >
commentString: string; import { ref } from 'vue';
}>();
const emit = defineEmits<{ const { commentString } = defineProps<{
clickClose: []; commentString: string;
clickSave: [comment: string]; }>();
}>();
const text = ref(props.commentString); const emit = defineEmits<{
clickClose: [];
clickSave: [comment: string];
}>();
const close = ()=> { const text = ref(commentString);
emit('clickClose');
text.value = '';
}
const save = ()=> { const close = () => {
emit('clickSave',text.value); emit('clickClose');
close(); text.value = '';
} }
const save = () => {
emit('clickSave', text.value);
close();
}
</script> </script>
<template> <template>
@ -28,14 +31,14 @@ const save = ()=> {
<div class="row items-center justify-between q-pa-md"> <div class="row items-center justify-between q-pa-md">
{{ $t('timesheet.fields.header_comment') }} {{ $t('timesheet.fields.header_comment') }}
</div> </div>
<q-separator/> <q-separator />
<div class="q-pa-md"> <div class="q-pa-md">
<q-input <q-input
v-model="text" v-model="text"
type="textarea" type="textarea"
autogrow autogrow
filled filled
:label= "$t('timesheet.fields.textarea_comment')" :label="$t('timesheet.fields.textarea_comment')"
:counter=true :counter=true
maxlength="512" maxlength="512"
color="primary" color="primary"
@ -46,8 +49,8 @@ const save = ()=> {
color="secondary" color="secondary"
text-color="grey-8" text-color="grey-8"
:label="$t('timesheet.cancel_button')" :label="$t('timesheet.cancel_button')"
@click="close" @click="close"
/> />
<q-btn <q-btn
:label="$t('timesheet.save_button')" :label="$t('timesheet.save_button')"
color="primary" color="primary"

View File

@ -0,0 +1,58 @@
<script
setup
lang="ts"
>
import { useExpensesStore } from 'src/stores/expense-store';
import ExpenseCrudDialogList from 'src/modules/timesheets/components/expense-crud-dialog-list.vue';
import ExpenseCrudDialogForm from 'src/modules/timesheets/components/expense-crud-dialog-form.vue';
import ExpenseCrudDialogHeader from 'src/modules/timesheets/components/expense-crud-dialog-header.vue';
const expense_store = useExpensesStore();
</script>
<template>
<q-dialog
v-model="expense_store.is_open"
persistent
>
<q-card
class="q-pa-md"
style=" min-width: 70vw;"
>
<q-inner-loading :showing="expense_store.is_loading">
<q-spinner size="32px" />
</q-inner-loading>
<q-card-section>
<!-- <q-banner
v-if="expenses_error"
dense
class="bg-red-2 col-auto text-negative q-mt-sm"
>
{{ expenses_error }}
</q-banner> -->
<ExpenseCrudDialogHeader />
<ExpenseCrudDialogList />
<ExpenseCrudDialogForm />
<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

@ -1,117 +0,0 @@
<script setup lang="ts">
import type { TimesheetExpense } from '../../types/expense.interfaces';
import { expenseTypeIcon } from '../../utils/expense.util';
/* eslint-disable */
defineProps<{
items: TimesheetExpense[];
is_readonly: boolean;
}>();
defineEmits<{
(e: 'remove', index: number): void;
(e: 'edit' , index: number): void;
}>();
</script>
<template>
<!-- liste des dépenses pré existantes -->
<q-list
padding
class="rounded-borders"
>
<q-item-label v-if="items.length === 0" class="text-italic q-px-sm">
{{ $t('timesheet.expense.empty_list') }}
</q-item-label>
<q-item
style="border: solid 1px lightgrey; border-radius: 7px;"
v-for="(expense, index) in items" :key="index"
class="q-my-xs shadow-1"
>
<!-- avatar type icon section -->
<q-item-section avatar>
<q-icon :name="expenseTypeIcon(expense.type)" color="primary"/>
</q-item-section>
<!-- amount or mileage section -->
<q-item-section top>
<q-item-label v-if="String(expense.type).trim().toUpperCase() === 'MILEAGE'">
<template v-if="typeof expense.mileage === 'number'">
{{ expense.mileage?.toFixed(1) }} km
</template>
<template v-else>
{{ expense.amount?.toFixed(2) }} $
</template>
</q-item-label>
<q-item-label v-else>
{{ expense.amount?.toFixed(2) }} $
</q-item-label>
<!-- date label -->
<q-item-label caption lines="2">
{{ $d(new Date(expense.date + 'T00:00:00'), { year:'numeric', month:'short', day: 'numeric', weekday: 'short'}) }}
</q-item-label>
</q-item-section>
<!-- attachment file icon -->
<q-item-section side>
<q-btn
push
dense
size="md"
color="primary"
class="q-mx-lg"
icon="attach_file"
/>
</q-item-section>
<!-- comment section -->
<q-item-section top>
<q-item-label lines="1">
{{ $t('timesheet.expense.employee_comment') }}
</q-item-label>
<q-item-label caption lines="2">
{{ expense.comment }}
</q-item-label>
</q-item-section>
<!-- supervisor comment section -->
<q-item-section top>
<q-item-label lines="1">
{{ $t('timesheet.expense.supervisor_comment') }}
</q-item-label>
<q-item-label v-if="expense.supervisor_comment" caption lines="2">
{{ expense.supervisor_comment }}
</q-item-label>
</q-item-section>
<!-- delete btn -->
<q-item-section side>
<q-btn
v-if="!is_readonly"
push
dense
size="xs"
color="primary"
icon="edit"
@click="$emit('edit', index)"
/>
</q-item-section>
<!-- delete btn -->
<q-item-section side>
<q-btn
v-if="!is_readonly"
push
dense
size="xs"
color="negative"
icon="close"
@click="$emit('remove', index)"
/>
</q-item-section>
</q-item>
</q-list>
</template>

View File

@ -1,267 +0,0 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import { computed, ref } from 'vue';
import { useExpenseForm } from '../../composables/use-expense-form';
import { useExpenseDraft } from '../../composables/use-expense-draft';
import { useExpenseItems } from '../../composables/use-expense-items';
import { useToggle } from 'src/modules/shared/composables/use-toggle';
import ExpenseList from './expense-list.vue';
import ExpenseForm from './expense-form.vue';
import {
buildExpenseTypeOptions,
computeExpenseTotals,
makeExpenseRules,
buildExpenseSavePayload
} from '../../utils/expense.util';
import { COMMENT_MAX_LENGTH } from '../../constants/expense.constants';
import { ExpensesValidationError } from '../../types/expense-validation.interface';
import { EXPENSE_TYPE } from '../../types/expense.types';
import type { ExpenseType } from '../../types/expense.types';
import type { ExpenseDay, TimesheetExpense } from '../../types/expense.interfaces';
import {
createExpenseByDate,
deleteExpenseByDate,
getPayPeriodExpenses,
updateExpenseByDate
} from '../../composables/api/use-expense-api';
/* eslint-disable */
const { t, locale } = useI18n();
const rules = makeExpenseRules(t, COMMENT_MAX_LENGTH);
//------------------ props ------------------
const {email, pay_period_no, pay_year, is_approved, initial_expenses} = defineProps<{
pay_period_no: number;
pay_year: number;
email: string;
is_approved?: boolean;
initial_expenses?: TimesheetExpense[];
}>();
//------------------ emits ------------------
const emit = defineEmits<{
(e: 'close'): void;
(e: 'save', payload: {
pay_period_no: number;
pay_year: number;
email: string;
expenses: TimesheetExpense[];
}): void;
(e: 'error', err: ExpensesValidationError): void;
}>();
//------------------ q-select mapper ------------------
const type_options = computed(() => {
void locale.value;
return buildExpenseTypeOptions(EXPENSE_TYPE, t);
})
//------------------ refs and computed ------------------
const files = ref<File[] | null>(null);
const is_readonly = computed(() => !!is_approved);
const editing_old = ref<ExpenseDay | null>(null);
const { state: is_open_date_picker } = useToggle();
const { draft, setType, reset, showAmount } = useExpenseDraft();
const { formRef, validateAnd } = useExpenseForm();
const { items, validateAll, payload } = useExpenseItems({
initial_expenses: initial_expenses,
is_approved: is_readonly,
draft,
});
const totals = computed(() => computeExpenseTotals(items.value));
//------------------ actions ------------------
const onSave = () => {
try {
validateAll();
reset();
emit('save', buildExpenseSavePayload({
pay_period_no: pay_period_no,
pay_year: pay_year,
email: email,
expenses: payload(),
}));
} catch (err: any) {
const e = err instanceof ExpensesValidationError
? err
: new ExpensesValidationError({
status_code: 400,
message: String(err?.message || err)
});
emit('error', e);
}
};
const refreshFromServer = async () => {
const fresh = await getPayPeriodExpenses(email, pay_year, pay_period_no);
items.value = Array.isArray(fresh.expenses) ? fresh.expenses : [];
};
const onFormSubmit = async () => {
try {
await validateAnd(async () => {
if (is_readonly.value) throw new Error(t('common.read_only') || 'Read-only');
const day = draft.value;
if (!day?.date || !day?.type || !day?.comment) {
throw new ExpensesValidationError({ status_code: 400, message: 'Missing required fields' });
}
const is_mileage = String(day.type).toUpperCase() === 'MILEAGE';
const new_payload = {
date: day.date,
type: day.type as ExpenseType,
comment: day.comment,
...(is_mileage && typeof day.mileage === 'number'
? { mileage: day.mileage }
: !is_mileage && typeof day.amount === 'number'
? { amount: day.amount }
: {}),
};
if(editing_old.value) {
await updateExpenseByDate(email, editing_old.value, new_payload as any);
editing_old.value = null;
} else {
await createExpenseByDate(email, new_payload as any);
}
await refreshFromServer();
reset();
});
} catch (err: any) {
const e = err instanceof ExpensesValidationError ? err : new ExpensesValidationError({
status_code: 400,
message: String(err?.message || err)
});
emit('error', e);
}
};
const onRemove = async (index: number) => {
try {
if (is_readonly.value) throw new Error(t('common.read_only') || 'Read-only');
const item = items.value[index];
if (!item) return;
const is_mileage = String(item.type).toUpperCase() === 'MILEAGE';
const old_payload: any = {
date: item.date,
type: item.type as ExpenseType,
comment: item.comment ?? '',
...(is_mileage && typeof item.mileage === 'number'
? { mileage: item.mileage }
: !is_mileage && typeof item.amount === 'number'
? { amount: item.amount }
: {}),
};
await deleteExpenseByDate(email, old_payload as any);
await refreshFromServer();
} catch (err: any) {
const e = err instanceof ExpensesValidationError ? err : new ExpensesValidationError({
status_code: 400, message: String(err?.message || err)
});
emit('error', e);
}
};
const onEdit = async (index: number) => {
if(is_readonly) return;
const item = items.value[index];
if(!item) return;
const old_amount = Number(item.amount) || 0;
editing_old.value = {
date: item.date,
type: item.type as ExpenseType,
amount: old_amount,
comment: item.comment ?? '',
is_approved: !!item.is_approved,
};
const is_mileage = String(item.type).toUpperCase() === 'MILEAGE';
const next: Partial<TimesheetExpense> = {
date: item.date,
type: item.type,
comment: item.comment ?? '',
...(is_mileage
? (typeof item.mileage === 'number' ? { mileage: item.mileage } : {})
: (typeof item.amount === 'number' ? { amnount: item.amount } : {})),
};
(draft as any).value = next;
setType(item.type as ExpenseType);
};
const onClose = () => emit('close');
</script>
<template>
<div>
<!-- header (title with totals)-->
<q-item class="row justify-between">
<q-item-label
header
class="text-h6 col-auto"
>
{{ $t('timesheet.expense.title') }}
</q-item-label>
<q-item-section class="items-center col-auto">
<q-badge
lines="1"
class="q-pa-sm q-px-md"
:label="$t('timesheet.expense.total_amount') + ': ' + totals.amount.toFixed(2)"
/>
<q-separator spaced />
<q-badge
lines="2"
class="q-pa-sm q-px-md"
:label="$t('timesheet.expense.total_mileage') + ': ' + totals.mileage.toFixed(1)"
/>
</q-item-section>
</q-item>
<ExpenseList
:items="items"
:is_readonly="is_readonly"
@remove="onRemove"
@edit="onEdit"
/>
<ExpenseForm
ref="formRef"
v-model:draft="draft"
v-model:files="files"
v-model:date-picker-open="is_open_date_picker"
:type_options="type_options"
:show_amount="showAmount"
:is_readonly="is_readonly"
:rules="rules"
:comment_max_length="COMMENT_MAX_LENGTH"
:set-type="setType"
@submit="onFormSubmit"
/>
<q-separator spaced />
<div class="row col-auto justify-end">
<!-- close btn -->
<q-btn
flat
class="q-mr-sm"
color="primary"
:label="$t('timesheet.cancel_button')"
@click="onClose"
/>
<!-- save btn -->
<q-btn
color="primary"
unelevated
push
:disable="is_readonly || items.length === 0"
:label="$t('timesheet.save_button')"
@click="onSave"
/>
</div>
</div>
</template>

View File

@ -0,0 +1,173 @@
<script
setup
lang="ts"
>
import { computed, ref } from 'vue';
import { useShiftStore } from 'src/stores/shift-store';
import { useShiftApi } from 'src/modules/timesheets/composables/api/use-shift-api';
import { SHIFT_TYPES } from 'src/modules/timesheets/models/shift.models';
const { date_iso, mode, current_shift, is_open, close } = useShiftStore();
const { upsertOrDeleteShiftByEmployeeEmail } = useShiftApi();
const { employeeEmail } = defineProps<{
employeeEmail: string;
}>();
const isSubmitting = ref(false);
const errorBanner = ref<string | null>(null);
const conflicts = ref<Array<{ start_time: string; end_time: string; type: string }>>([]);
const canSubmit = computed(() =>
mode === 'delete' ||
(current_shift.start_time.trim().length === 5 &&
current_shift.end_time.trim().length === 5 &&
current_shift.type !== undefined)
);
</script>
<template>
<q-dialog
v-model=" is_open"
persistent
transition-show="fade"
transition-hide="fade"
>
<q-card class="q-pa-md">
<div class="row items-center q-mb-sm">
<q-icon
name="schedule"
size="24px"
class="q-mr-sm"
/>
<div class="text-h6">
{{
mode === 'create'
? $t('timesheet.shift.actions.add')
: mode === 'update'
? $t('timesheet.shift.actions.edit')
: $t('timesheet.shift.actions.delete')
}}
</div>
<q-space />
<q-badge
outline
color="primary"
>
{{ date_iso }}
</q-badge>
</div>
<q-separator spaced />
<div
v-if="mode !== 'delete'"
class="column q-gutter-md"
>
<div class="row ">
<div class="col">
<q-input
v-model="current_shift.start_time"
:label="$t('timesheet.shift.fields.start')"
filled
dense
inputmode="numeric"
mask="##:##"
/>
</div>
<div class="col">
<q-input
v-model="current_shift.end_time"
:label="$t('timesheet.shift.fields.end')"
filled
dense
inputmode="numeric"
mask="##:##"
/>
</div>
</div>
<div class="row items-center">
<q-select
v-model="current_shift.type"
options-dense
:options="SHIFT_TYPES"
:label="$t('timesheet.shift.types.label')"
class="col"
color="primary"
filled
dense
hide-dropdown-icon
emit-value
map-options
/>
<q-toggle
v-model="current_shift.is_remote"
:label="$t('timesheet.shift.types.REMOTE')"
class="col-auto"
/>
</div>
<q-input
v-model="current_shift.comment"
type="textarea"
autogrow
filled
dense
:label="$t('timesheet.shift.fields.header_comment')"
:counter="true"
:maxlength="512"
/>
</div>
<div
v-else
class="q-pa-md"
>
{{ $t('timesheet.shift.actions.delete_confirmation_msg') }}
</div>
<div
v-if="errorBanner"
class="q-mt-md"
>
<q-banner
dense
class="bg-red-2 text-negative"
>{{ errorBanner }}</q-banner>
<div
v-if="conflicts.length"
class="q-mt-xs"
>
<div class="text-caption">Conflits :</div>
<ul class="q-pl-md q-mt-xs">
<li
v-for="(c, i) in conflicts"
:key="i"
>
{{ c.start_time }}{{ c.end_time }} ({{ c.type }})
</li>
</ul>
</div>
</div>
<q-separator spaced />
<div class="row justify-end q-gutter-sm">
<q-btn
flat
color="grey-8"
:label="$t('timesheet.cancel_button')"
@click="close"
/>
<q-btn
color="primary"
icon="save_alt"
:label="mode === 'delete' ? $t('timesheet.delete_button') : $t('timesheet.save_button')"
:loading="isSubmitting"
:disable="!canSubmit"
@click="upsertOrDeleteShiftByEmployeeEmail(employeeEmail)"
/>
</div>
</q-card>
</q-dialog>
</template>

View File

@ -1,33 +1,33 @@
<template> <template>
<q-card-section <q-card-section
horizontal horizontal
class="text-uppercase text-center items-center q-pa-none" class="text-uppercase text-center items-center q-pa-none"
> >
<!-- shift row itself --> <!-- shift row itself -->
<q-card-section class="col q-pa-none"> <q-card-section class="col q-pa-none">
<q-card-section horizontal class="col q-pa-none"> <q-card-section horizontal class="col q-pa-none">
<!-- punch-in timestamps --> <!-- punch-in timestamps -->
<q-card-section class="col q-pa-none"> <q-card-section class="col q-pa-none">
<q-item-label class="text-weight-bolder text-primary" style="font-size: 0.7em;"> <q-item-label class="text-weight-bolder text-primary" style="font-size: 0.7em;">
{{ $t('shared.misc.in') }} {{ $t('shared.misc.in') }}
</q-item-label> </q-item-label>
</q-card-section> </q-card-section>
<!-- arrows pointing to punch-out timestamps --> <!-- arrows pointing to punch-out timestamps -->
<q-card-section class="col q-py-none q-px-sm"> <q-card-section class="col q-py-none q-px-sm">
</q-card-section> </q-card-section>
<!-- punch-out timestamps --> <!-- punch-out timestamps -->
<q-card-section class="col q-pa-none"> <q-card-section class="col q-pa-none">
<q-item-label class="text-weight-bolder text-primary" style="font-size: 0.7em;"> <q-item-label class="text-weight-bolder text-primary" style="font-size: 0.7em;">
{{ $t('shared.misc.out') }} {{ $t('shared.misc.out') }}
</q-item-label> </q-item-label>
</q-card-section> </q-card-section>
<!-- comment button --> <!-- comment button -->
<q-card-section class="col column q-pa-none"> <q-card-section class="col column q-pa-none">
</q-card-section> </q-card-section>
</q-card-section> </q-card-section>
</q-card-section> </q-card-section>
</q-card-section> </q-card-section>
</template> </template>

View File

@ -0,0 +1,72 @@
<script
setup
lang="ts"
>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import type { ShiftLegendItem } from 'src/modules/timesheets/models/shift.models';
const { t } = useI18n();
const is_showing_legend = ref(false);
const legend: ShiftLegendItem[] = [
{ type: 'REGULAR', color: 'secondary', label_type: 'timesheet.shift.types.REGULAR', text_color: 'grey-8' },
{ type: 'EVENING', color: 'warning', label_type: 'timesheet.shift.types.EVENING' },
{ type: 'EMERGENCY', color: 'amber-10', label_type: 'timesheet.shift.types.EMERGENCY' },
{ type: 'OVERTIME', color: 'negative', label_type: 'timesheet.shift.types.OVERTIME' },
{ type: 'VACATION', color: 'purple-10', label_type: 'timesheet.shift.types.VACATION' },
{ type: 'HOLIDAY', color: 'purple-5', label_type: 'timesheet.shift.types.HOLIDAY' },
{ type: 'SICK', color: 'grey-8', label_type: 'timesheet.shift.types.SICK' },
]
const shift_type_legend = computed(() =>
legend.map(item => ({ ...item, label: t(item.label_type) }))
);
</script>
<template>
<div
class="items-center"
:class="$q.screen.lt.md ? 'column' : 'row'"
>
<q-btn
flat
dense
rounded
color="primary"
class="col-auto q-ma-sm"
@click="is_showing_legend = !is_showing_legend"
>
<template #default>
<q-icon
:name="is_showing_legend ? 'close' : 'info_outline'"
size="md"
class="col-auto"
/>
</template>
</q-btn>
<transition
appear
enter-active-class="animated fadeIn"
leave-active-class="animated fadeOut"
class="col-auto"
>
<div
v-if="is_showing_legend"
class="q-pa-xs bg-white rounded-5 shadow-2 text-center q-ma-xs"
>
<q-badge
v-for="shift_type in shift_type_legend"
:key="shift_type.type"
:color="shift_type.color"
:label="shift_type.label"
:text-color="shift_type.text_color || 'white'"
class="q-px-md q-py-xs q-mx-xs q-my-none text-uppercase text-weight-bolder justify-center"
style="font-size: 0.8em;"
/>
</div>
</transition>
</div>
</template>

View File

@ -1,85 +1,87 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import type { Shift } from '../../types/shift.interfaces'; import type { Shift } from 'src/modules/timesheets/models/shift.models';
/* eslint-disable */
const props = defineProps<{ const { shift, dense = false } = defineProps<{
shift: Shift; shift: Shift;
dense?: boolean;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
'save-comment' : [payload: { comment: string; shift: Shift }]; 'save-comment': [comment: string, shift: Shift];
'request-edit' : [payload: { shift: Shift }]; 'request-update': [shift: Shift];
'request-delete': [payload: { shift: Shift }]; 'request-delete': [shift: Shift];
}>(); }>();
const has_comment = computed(()=> { const has_comment = computed(() => {
const comment = (props.shift as any).description ?? (props.shift as any).comment ?? ''; const comment = shift.comment ?? '';
return typeof comment === 'string' && comment.trim().length > 0; return typeof comment === 'string' && comment.trim().length > 0;
}) })
const comment_icon = computed(()=> (has_comment.value ? 'announcement' : 'chat_bubble_outline')); const comment_icon = computed(() => (has_comment.value ? 'announcement' : 'chat_bubble_outline'));
const comment_color = computed(()=> (has_comment.value ? 'primary' : 'grey-8')); const comment_color = computed(() => (has_comment.value ? 'primary' : 'grey-8'));
const hour_font_size = computed(() => dense ? '0.9em' : '1.5em' )
const get_shift_color = (type: string): string => { const get_shift_color = (type: string): string => {
switch(type) { switch (type) {
case 'REGULAR': return 'secondary'; case 'REGULAR': return 'secondary';
case 'EVENING': return 'warning'; case 'EVENING': return 'warning';
case 'EMERGENCY': return 'amber-10'; case 'EMERGENCY': return 'amber-10';
case 'OVERTIME': return 'negative'; case 'OVERTIME': return 'negative';
case 'VACATION': return 'purple-10'; case 'VACATION': return 'purple-10';
case 'HOLIDAY': return 'purple-10'; case 'HOLIDAY': return 'purple-5';
case 'SICK': return 'grey-8'; case 'SICK': return 'grey-8';
default : return 'transparent'; default: return 'transparent';
} }
}; };
const get_text_color = (type: string): string => { const get_text_color = (type: string): string => {
switch(type) { switch (type) {
case 'REGULAR': return 'grey-8'; case 'REGULAR': return 'grey-8';
case '': return 'grey-5'; case '': return 'grey-5';
default: return 'white'; default: return 'white';
} }
} }
const on_click_edit = (type: string) => {
if(type !== '') { emit('request-edit', { shift: props.shift })};
}
const on_click_delete = () => emit('request-delete', { shift: props.shift });
const onClickUpdate = (type: string) => {
if (type !== '') { emit('request-update', shift) };
}
const onClickDelete = () => emit('request-delete', shift);
</script> </script>
<template> <template>
<q-card-section <q-card-section
horizontal horizontal
class="q-pa-none text-uppercase text-center items-center rounded-10" class="q-pa-none text-uppercase text-center items-center rounded-10"
:class="props.shift.type" :class="shift.type"
style="line-height: 1;" style="line-height: 1;"
@click.stop="on_click_edit(props.shift.type)" @click.stop="onClickUpdate(shift.type)"
> >
<!-- punch-in timestamps --> <!-- punch-in timestamps -->
<q-card-section class="q-pa-none col"> <q-card-section class="q-pa-none col">
<q-item-label <q-item-label
class="text-weight-bolder q-pa-xs rounded-5" class="text-weight-bolder q-pa-xs rounded-5"
:class="'bg-' + get_shift_color(props.shift.type) + ' text-' + get_text_color(props.shift.type)" :class="'bg-' + get_shift_color(shift.type) + ' text-' + get_text_color(shift.type)"
style="font-size: 1.5em; line-height: 80% !important;" :style="'font-size: ' + hour_font_size + '; line-height: 80% !important;'"
> >
{{ props.shift.start_time }} {{ shift.start_time }}
</q-item-label> </q-item-label>
</q-card-section> </q-card-section>
<!-- arrows pointing to punch-out timestamps --> <!-- arrows pointing to punch-out timestamps -->
<q-card-section <q-card-section
horizontal horizontal
class="items-center justify-center q-mx-sm col" class="items-center justify-center q-mx-sm col"
> >
<div <div
v-for="icon_data, index in [ v-for="icon_data, index in [
{ transform: 'transform: translateX(5px);', color: 'accent' }, { transform: 'transform: translateX(5px);', color: 'accent' },
{ transform: 'transform: translateX(-5px);', color: 'primary' }]" { transform: 'transform: translateX(-5px);', color: 'primary' }]"
:key="index" :key="index"
> >
<q-icon <q-icon
v-if="props.shift.type" v-if="shift.type"
name="double_arrow" name="double_arrow"
:color="icon_data.color" :color="icon_data.color"
size="24px" size="24px"
@ -90,22 +92,20 @@ const on_click_delete = () => emit('request-delete', { shift: props.shift });
<!-- punch-out timestamps --> <!-- punch-out timestamps -->
<q-card-section class="q-pa-none col"> <q-card-section class="q-pa-none col">
<q-item-label <q-item-label
class="text-weight-bolder text-white q-pa-xs rounded-5" class="text-weight-bolder text-white q-pa-xs rounded-5"
:class="'bg-' + get_shift_color(props.shift.type) + ' text-' + get_text_color(props.shift.type)" :class="'bg-' + get_shift_color(shift.type) + ' text-' + get_text_color(shift.type)"
style="font-size: 1.5em; line-height: 80% !important;" style="font-size: 1.5em; line-height: 80% !important;"
> >
{{ props.shift.end_time }} {{ shift.end_time }}
</q-item-label> </q-item-label>
</q-card-section> </q-card-section>
<!-- comment and expenses buttons --> <!-- comment and expenses buttons -->
<q-card-section <q-card-section class="col q-pa-none text-right">
class="col q-pa-none text-right"
>
<!-- comment btn --> <!-- comment btn -->
<q-icon <q-icon
v-if="props.shift.type" v-if="shift.type"
:name="comment_icon" :name="comment_icon"
:color="comment_color" :color="comment_color"
class="q-pa-none q-mx-xs" class="q-pa-none q-mx-xs"
@ -113,23 +113,23 @@ const on_click_delete = () => emit('request-delete', { shift: props.shift });
/> />
<!-- expenses btn --> <!-- expenses btn -->
<q-btn <q-btn
v-if="props.shift.type" v-if="shift.type"
flat flat
dense dense
color='grey-8' color='grey-8'
icon="attach_money" icon="attach_money"
class="q-pa-none q-mx-xs" class="q-pa-none q-mx-xs"
/> />
<!-- delete btn --> <!-- delete btn -->
<q-btn <q-btn
v-if="props.shift.type" v-if="shift.type"
push push
dense dense
size="sm" size="sm"
color="red-6" color="red-6"
icon="close" icon="close"
class="q-ml-xs" class="q-ml-xs"
@click.stop="on_click_delete" @click.stop="onClickDelete"
/> />
</q-card-section> </q-card-section>
</q-card-section> </q-card-section>

View File

@ -0,0 +1,98 @@
<script
setup
lang="ts"
>
import { date } from 'quasar';
import ShiftListHeader from 'src/modules/timesheets/components/shift-list-header.vue';
import ShiftListRow from 'src/modules/timesheets/components/shift-list-row.vue';
import { useShiftStore } from 'src/stores/shift-store';
import { useTimesheetStore } from 'src/stores/timesheet-store';
import { type Shift, default_shift } from 'src/modules/timesheets/models/shift.models';
import { computed } from 'vue';
const timesheet_store = useTimesheetStore();
const { openCreate, openDelete, openUpdate } = useShiftStore();
const { dense = false } = defineProps<{
dense?: boolean;
}>();
const font_size = computed(() => dense ? '1.5em' : '2.5em')
const get_date_from_short = (short_date: string): Date => {
return new Date(timesheet_store.pay_period.pay_year.toString() + '/' + short_date);
};
const to_iso_date = (short_date: string): string => {
return date.formatDate(get_date_from_short(short_date), 'YYYY-MM-DD');
};
const shifts_or_placeholder = (shifts: Shift[]): Shift[] => {
return shifts.length > 0 ? shifts : [default_shift];
};
const getDate = (shift_date: string): Date => {
return new Date(timesheet_store.pay_period.pay_year.toString() + '/' + shift_date);
};
</script>
<template>
<div
v-for="week, index in timesheet_store.pay_period_details.weeks"
:key="index"
class="col q-px-xs q-pt-xs q-mx-sm rounded-5"
>
<q-card
v-for="day, day_index in week.shifts"
:key="day_index + index"
class="row items-center rounded-10 q-mb-xs"
>
<!-- Dates column -->
<q-card-section class="col-auto q-pa-xs text-white q-mr-md">
<div
class="bg-primary rounded-10 q-pa-xs text-center"
:style="'width: ' + dense? '60px' : '75px;'"
>
<q-item-label
style="font-size: 0.7em;"
class="text-uppercase"
>{{ $d(getDate(day.short_date), { weekday: $q.screen.lt.md ? 'short' : 'long' }) }}</q-item-label>
<q-item-label
class="text-weight-bolder"
:style="'font-size: ' + font_size + '; line-height: 90% !important;'"
>{{ day.short_date.split('/')[1] }}</q-item-label>
<q-item-label
style="font-size: 0.7em;"
class="text-uppercase"
>{{ $d(getDate(day.short_date), { month: $q.screen.lt.md ? 'short' : 'long' }) }}</q-item-label>
</div>
</q-card-section>
<!-- List of shifts column -->
<q-card-section class="col q-pa-none">
<ShiftListHeader v-if="day.shifts.length > 0"/>
<div
v-if="day.shifts.length > 0"
>
<ShiftListRow
v-for="shift, shift_index in shifts_or_placeholder(day.shifts)"
:key="shift_index"
:shift="shift"
@request-update="value => openUpdate(to_iso_date(day.short_date), value)"
@request-delete="value => openDelete(to_iso_date(day.short_date), value)"
/>
</div>
</q-card-section>
<!-- add shift btn column -->
<q-card-section class="q-pr-xs col-auto">
<q-btn
push
color="primary"
icon="more_time"
class="q-pa-sm"
@click="openCreate(to_iso_date(day.short_date))"
/>
</q-card-section>
</q-card>
</div>
</template>

View File

@ -1,33 +0,0 @@
<template>
<q-card-section
horizontal
class="text-uppercase text-center items-center q-pa-none"
>
<!-- shift row itself -->
<q-card-section class="col q-pa-none">
<q-card-section horizontal class="col q-pa-none">
<!-- punch-in timestamps -->
<q-card-section class="col q-pa-none">
<q-item-label class="text-weight-bolder text-primary" style="font-size: 0.7em;">
{{ $t('shiftColumns.labelIn') }}
</q-item-label>
</q-card-section>
<!-- arrows pointing to punch-out timestamps -->
<q-card-section class="col q-py-none q-px-sm">
</q-card-section>
<!-- punch-out timestamps -->
<q-card-section class="col q-pa-none">
<q-item-label class="text-weight-bolder text-primary" style="font-size: 0.7em;">
{{ $t('shiftColumns.labelOut') }}
</q-item-label>
</q-card-section>
<!-- comment button -->
<q-card-section class="col column q-pa-none">
</q-card-section>
</q-card-section>
</q-card-section>
</q-card-section>
</template>

View File

@ -1,97 +0,0 @@
<script setup lang="ts">
import type { PayPeriod } from 'src/modules/shared/types/pay-period-interface';
import detailedShiftListHeader from './detailed-shift-list-header.vue';
import detailedShiftListRow from './detailed-shift-list-row.vue';
import { date } from 'quasar';
import type { TimesheetPayPeriodDetailsOverview } from '../../types/timesheet.interfaces';
import type { Shift } from '../../types/shift.interfaces';
import { default_shift } from '../../types/shift.defaults';
const props = defineProps<{
rawData: TimesheetPayPeriodDetailsOverview;
currentPayPeriod: PayPeriod;
}>();
const emit = defineEmits<{
'request-add' : [payload: { date: string }];
'request-edit' : [payload: { date: string; shift: Shift }];
'request-delete' : [payload: { date: string; shift: Shift }];
// 'save-comment' : [payload: { date: string; shift: Shift; comment: string }];
}>();
const get_date_from_short = (short_date: string):
Date => new Date(props.currentPayPeriod.pay_year.toString() + '/' + short_date);
const to_iso_date = (short_date: string):
string => date.formatDate(get_date_from_short(short_date), 'YYYY-MM-DD');
const shifts_or_placeholder = (shifts: Shift[]):
Shift[] => { return shifts.length > 0 ? shifts : [default_shift]; };
const getDate = (shift_date: string): Date => {
return new Date(props.currentPayPeriod.pay_year.toString() + '/' + shift_date);
};
const on_request_add = (iso_date: string) => emit('request-add', { date: iso_date });
const on_request_edit = (iso_date: string, shift: Shift) => emit('request-edit', { date: iso_date, shift });
const on_request_delete = (iso_date: string, shift: Shift) => emit('request-delete', { date: iso_date, shift });
// const on_save_comment = (iso_date: string, shift: Shift, comment: string) => emit('save-comment', { date: iso_date, shift, comment });
</script>
<template>
<div
v-for="week, index in props.rawData"
:key="index"
class="q-px-xs q-pt-xs rounded-5 col"
>
<q-card
v-for="day, day_index in week.shifts"
:key="day_index"
flat
bordered
class="row items-center rounded-10 q-mb-xs"
>
<!-- Dates column -->
<q-card-section class="col-auto q-pa-xs text-white">
<div
class="bg-primary rounded-10 q-pa-xs text-center"
>
<q-item-label
style="font-size: 0.7em;"
class="text-uppercase"
>{{ $d(getDate(day.short_date), {weekday: $q.screen.lt.md ? 'short' : 'long'}) }}</q-item-label>
<q-item-label
class="text-weight-bolder"
style="font-size: 2.5em; line-height: 90% !important;"
>{{ day.short_date.split('/')[1] }}</q-item-label>
<q-item-label
style="font-size: 0.7em;"
class="text-uppercase"
>{{ $d(getDate(day.short_date), {month: $q.screen.lt.md ? 'short' : 'long'}) }}</q-item-label>
</div>
</q-card-section>
<!-- List of shifts column -->
<q-card-section class="col q-pa-none">
<detailedShiftListHeader />
<detailedShiftListRow
v-for="shift, shift_index in shifts_or_placeholder(day.shifts)"
:key="shift_index"
:shift="shift"
@request-edit=" ({ shift }) => on_request_edit(to_iso_date(day.short_date), shift )"
@request-delete="({ shift }) => on_request_delete(to_iso_date(day.short_date), shift )"
/>
</q-card-section>
<!-- add shift btn column -->
<q-card-section class="q-pr-xs col-auto">
<q-btn
push
color="primary"
icon="more_time"
class="q-pa-sm"
@click="on_request_add(to_iso_date(day.short_date))"
/>
</q-card-section>
</q-card>
</div>
</template>

View File

@ -1,282 +0,0 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { SHIFT_KEY, type ShiftKey, type ShiftPayload, type ShiftSelectOption } from '../../types/shift.types';
import type { UpsertShiftsBody } from '../../types/shift.interfaces';
import { upsertShiftsByDate } from '../../composables/api/use-shift-api';
/* eslint-disable */
const { t } = useI18n();
const props = defineProps<{
mode: 'create' | 'edit' | 'delete';
dateIso: string;
initialShift?: ShiftPayload | null;
shiftOptions: ShiftSelectOption[];
email: string;
}>();
const emit = defineEmits<{
'close': []
'saved': []
}>();
const isSubmitting = ref(false);
const errorBanner = ref<string | null>(null);
const conflicts = ref<Array<{ start_time: string; end_time: string; type: string }>>([]);
const opened = defineModel<boolean>({ default: false });
const startTime = defineModel<string>('startTime', { default: '' });
const endTime = defineModel<string>('endTime', { default: '' });
const type = defineModel<ShiftKey | ''>('type', { default: '' });
const isRemote = defineModel<boolean>('isRemote', { default: false });
const comment = defineModel<string>('comment', { default: '' });
const isShiftKey = (val: unknown): val is ShiftKey => SHIFT_KEY.includes(val as ShiftKey);
const buildNewShiftPayload = (): ShiftPayload => {
if (!isShiftKey(type.value)) throw new Error('Invalid shift type');
const trimmed = (comment.value ?? '').trim();
return {
start_time: startTime.value,
end_time: endTime.value,
type: type.value,
is_remote: isRemote.value,
...(trimmed ? { comment: trimmed } : {}),
};
};
const onSubmit = async () => {
errorBanner.value = null;
conflicts.value = [];
isSubmitting.value = true;
try {
let body: UpsertShiftsBody;
if (props.mode === 'create') {
body = { new_shift: buildNewShiftPayload() };
} else if (props.mode === 'edit') {
if (!props.initialShift) throw new Error('Missing initial Shift for edit');
body = { old_shift: props.initialShift, new_shift: buildNewShiftPayload() };
} else {
if (!props.initialShift) throw new Error('Missing initial Shift for delete.');
body = { old_shift: props.initialShift };
}
await upsertShiftsByDate(props.email, props.dateIso, body);
opened.value = false;
emit('saved');
} catch (error: any) {
const status = error?.status_code ?? error.response?.status ?? 500;
const apiConflicts = error?.response?.data?.conflicts ?? error?.data?.conflicts;
if (Array.isArray(apiConflicts)) {
conflicts.value = apiConflicts.map((c: any) => ({
start_time: String(c.start_time ?? ''),
end_time: String(c.end_time ?? ''),
type: String(c.type ?? ''),
}));
} else {
conflicts.value = [];
}
if (status === 404) errorBanner.value = t('timesheet.shift.errors.not_found')
else if (status === 409) errorBanner.value = t('timesheet.shift.errors.overlap')
else if (status === 422) errorBanner.value = t('timesheet.shift.errors.invalid')
else errorBanner.value = t('timesheet.shift.errors.unknown')
//add conflicts.value error management
} finally {
isSubmitting.value = false;
}
}
const hydrateFromProps = () => {
if (props.mode === 'edit' || props.mode === 'delete') {
const shift = props.initialShift;
startTime.value = shift?.start_time ?? '';
endTime.value = shift?.end_time ?? '';
type.value = shift?.type ?? '';
isRemote.value = !!shift?.is_remote;
comment.value = (shift as any)?.comment ?? '';
} else {
startTime.value = '';
endTime.value = '';
type.value = '';
isRemote.value = false;
comment.value = '';
}
};
const canSubmit = computed(() =>
props.mode === 'delete' ||
(startTime.value.trim().length === 5 &&
endTime.value.trim().length === 5 &&
isShiftKey(type.value))
);
watch(
() => [opened.value, props.mode, props.initialShift, props.dateIso],
() => { if (opened.value) hydrateFromProps(); },
{ immediate: true }
);
</script>
<!-- create/edit/delete shifts dialog -->
<template>
<q-dialog
v-model="opened"
persistent
transition-show="fade"
transition-hide="fade"
>
<q-card class="q-pa-md">
<div class="row items-center q-mb-sm">
<q-icon
name="schedule"
size="24px"
class="q-mr-sm"
/>
<div class="text-h6">
{{
props.mode === 'create'
? $t('timesheet.shift.actions.add')
: props.mode === 'edit'
? $t('timesheet.shift.actions.edit')
: $t('timesheet.shift.actions.delete')
}}
</div>
<q-space />
<q-badge
outline
color="primary"
>
{{ props.dateIso }}
</q-badge>
</div>
<q-separator spaced />
<div
v-if="props.mode !== 'delete'"
class="column q-gutter-md"
>
<div class="row ">
<div class="col">
<q-input
v-model="startTime"
:label="$t('timesheet.shift.fields.start')"
filled
dense
inputmode="numeric"
mask="##:##"
/>
</div>
<div class="col">
<q-input
v-model="endTime"
:label="$t('timesheet.shift.fields.end')"
filled
dense
inputmode="numeric"
mask="##:##"
/>
</div>
</div>
<div class="row items-center">
<q-select
v-model="type"
options-dense
:options="props.shiftOptions"
:label="$t('timesheet.shift.types.label')"
class="col"
color="primary"
filled
dense
hide-dropdown-icon
emit-value
map-options
/>
<q-toggle
v-model="isRemote"
:label="$t('timesheet.shift.types.REMOTE')"
class="col-auto"
/>
</div>
<q-input
v-model="comment"
type="textarea"
autogrow
filled
dense
:label="$t('timesheet.shift.fields.header_comment')"
:counter="true"
:maxlength="512"
/>
</div>
<div
v-else
class="q-pa-md"
>
{{ $t('timesheet.shift.actions.delete_confirmation_msg') }}
</div>
<div
v-if="errorBanner"
class="q-mt-md"
>
<q-banner
dense
class="bg-red-2 text-negative"
>{{ errorBanner }}</q-banner>
<div
v-if="conflicts.length"
class="q-mt-xs"
>
<div class="text-caption">Conflits :</div>
<ul class="q-pl-md q-mt-xs">
<li
v-for="(c, i) in conflicts"
:key="i"
>
{{ c.start_time }}{{ c.end_time }} ({{ c.type }})
</li>
</ul>
</div>
</div>
<q-separator spaced />
<div class="row justify-end q-gutter-sm">
<q-btn
flat
color="grey-8"
:label="$t('timesheet.cancel_button')"
@click="() => { opened = false; emit('close'); }"
/>
<q-btn
v-if="props.mode === 'delete'"
outline
color="negative"
icon="cancel"
:label="$t('timesheet.delete_button')"
:loading="isSubmitting"
:disable="!canSubmit"
@click="onSubmit"
/>
<q-btn
v-else
color="primary"
icon="save_alt"
:label="$t('timesheet.save_button')"
:loading="isSubmitting"
:disable="!canSubmit"
@click="onSubmit"
/>
</div>
</q-card>
</q-dialog>
</template>

View File

@ -1,42 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import type { ShiftLegendItem } from '../../types/shift.types';
const { t } = useI18n();
const props = defineProps<{ isLoading: boolean; }>();
const legend: ShiftLegendItem[] = [
{type:'REGULAR' , color: 'secondary', label_key: 'timesheet.shift.types.REGULAR', text_color: 'grey-8'},
{type:'EVENING' , color: 'warning' , label_key: 'timesheet.shift.types.EVENING'},
{type:'EMERGENCY', color: 'amber-10' , label_key: 'timesheet.shift.types.EMERGENCY'},
{type:'OVERTIME' , color: 'negative' , label_key: 'timesheet.shift.types.OVERTIME'},
{type:'VACATION' , color: 'purple-10', label_key: 'timesheet.shift.types.VACATION'},
{type:'HOLIDAY' , color: 'purple-8' , label_key: 'timesheet.shift.types.HOLIDAY'},
{type:'SICK' , color: 'grey-8' , label_key: 'timesheet.shift.types.SICK'},
]
const shift_type_legend = computed(()=>
legend.map(item => ({ ...item, label: t(item.label_key)} ))
);
</script>
<template>
<q-card class="q-px-xs q-pt-xs col rounded-10 q-mx-xs q-py-xs">
<q-card-section
class="q-py-xs q-pa-none text-center q-my-s"
v-if="!props.isLoading"
>
<q-badge
v-for="shift_type in shift_type_legend"
:key="shift_type.type"
:color="shift_type.color"
:label="shift_type.label"
:text-color="shift_type.text_color || 'white'"
class="q-px-md q-py-xs q-mx-xs q-my-none text-uppercase text-weight-bolder justify-center"
style="width: 120px; font-size: 0.8em;"
/>
</q-card-section>
</q-card>
</template>

View File

@ -0,0 +1,88 @@
<script
setup
lang="ts"
>
import ShiftList from 'src/modules/timesheets/components/shift-list.vue';
import ShiftCrudDialog from 'src/modules/timesheets/components/shift-crud-dialog.vue';
import ExpenseCrudDialog from 'src/modules/timesheets/components/expense-crud-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 { useTimesheetStore } from 'src/stores/timesheet-store';
import { useTimesheetApi } from 'src/modules/timesheets/composables/api/use-timesheet-api';
import { useExpensesStore } from 'src/stores/expense-store';
import { provide } from 'vue';
const { open } = useExpensesStore();
const { employeeEmail, dense = false } = defineProps<{
employeeEmail: string;
dense?: boolean;
}>();
const { is_loading } = useTimesheetStore();
const { getPayPeriodDetailsByDate, getPreviousPayPeriodDetails, getNextPayPeriodDetails } = useTimesheetApi();
provide('employeeEmail', employeeEmail);
</script>
<template>
<q-card
flat
class="q-mt-md bg-secondary full-width"
>
<q-inner-loading
:showing="is_loading"
color="primary"
/>
<q-card-section
:horizontal="$q.screen.gt.sm"
class="q-px-lg items-center"
:class="$q.screen.lt.md ? 'column' : ''"
>
<!-- navigation btn -->
<PayPeriodNavigator
@date-selected="getPayPeriodDetailsByDate"
@pressed-previous-button="getPreviousPayPeriodDetails"
@pressed-next-button="getNextPayPeriodDetails"
/>
<!-- mobile expenses button -->
<q-btn
v-if="$q.screen.lt.md"
push
rounded
color="primary"
icon="receipt_long"
:label="$t('timesheet.expense.open_btn')"
class="q-mt-sm"
@click="open(employeeEmail)"
/>
<!-- shift's colored legend -->
<ShiftListLegend :is-loading="false" />
<q-space />
<!-- desktop expenses button -->
<q-btn
v-if="$q.screen.gt.sm"
push
rounded
color="primary"
icon="receipt_long"
:label="$t('timesheet.expense.open_btn')"
@click="open(employeeEmail)"
/>
</q-card-section>
<q-card-section :horizontal="$q.screen.gt.sm">
<ShiftList :dense="dense"/>
</q-card-section>
</q-card>
<ExpenseCrudDialog />
<ShiftCrudDialog :employee-email="employeeEmail" />
</template>

View File

@ -1,63 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { CreateShiftPayload, Shift } from '../../types/shift.interfaces';
/* eslint-disable */
const props = defineProps<{
rows: Shift[];
week_dates: string[];
}>();
const emit = defineEmits<{
(e: 'save', payload: CreateShiftPayload[]): void;
}>();
const buildPayload = (): CreateShiftPayload[] => {
const dates = props.week_dates;
if(!Array.isArray(dates) || dates.length === 0) return [];
return props.rows.flatMap((row, idx) => {
const date = dates[idx];
const has_data = !!(row.type || row.start_time || row.end_time || row.comment);
if(!date || !has_data) return [];
const item: CreateShiftPayload = {
date,
type: row.type,
start_time: row.start_time,
end_time: row.end_time,
is_remote: row.is_remote,
};
if(row.comment) item.comment = row.comment;
return[item];
})
};
const payload = computed(buildPayload);
const canSave = computed(()=> payload.value.length > 0);
const saveWeek = () => {
emit('save', payload.value);
}
</script>
<template>
<!-- save button -->
<q-btn
color="primary"
icon="save_alt"
class="col-1"
push
rounded
:disable="!canSave"
@click="saveWeek"
>
<q-tooltip
anchor="top middle"
self="center middle"
class="bg-primary text-uppercase text-weight-bold"
>{{ $t('timesheet.save_button') }}
</q-tooltip>
</q-btn>
</template>

View File

@ -1,225 +1,41 @@
import { api } from "src/boot/axios"; import { normalizeObject } from "src/utils/normalize-object";
import { isProxy, toRaw } from "vue"; import { useExpensesStore } from "src/stores/expense-store";
import { normalizeExpense, validateExpenseUI } from "../../utils/expenses-validators"; import { expense_validation_schema } from "src/modules/timesheets/models/expense.validation";
import type { ExpenseType } from "../../types/expense.types"; import type { Expense, UpsertExpense } from "src/modules/timesheets/models/expense.models";
import { ExpensesApiError } from "../../types/expense-validation.interface";
import type {
ExpensePayload,
PayPeriodExpenses,
TimesheetExpense,
UpsertExpenseResult,
UpsertExpensesBody,
UpsertExpensesResponse
} from "../../types/expense.interfaces";
/* eslint-disable */ export const useExpensesApi = () => {
const toPlain = <T extends object>(obj:T): T => { const expenses_store = useExpensesStore();
const raw = isProxy(obj) ? toRaw(obj) : obj;
if( typeof (globalThis as any).structuredClone === 'function') {
return (globalThis as any).structuredClone(raw);
}
return JSON.parse(JSON.stringify(raw));
};
const normalizePayload = (expense: ExpensePayload): ExpensePayload => { const toUpsertExpense = (obj: {
const exp = normalizeExpense(expense as unknown as TimesheetExpense); old_expense?: Expense;
const out: ExpensePayload = { new_expense?: Expense;
date: exp.date, }) => obj as UpsertExpense;
type: exp.type as ExpenseType,
comment: exp.comment || '', const createExpenseByEmployeeEmail = async (employee_email: string, date: string): Promise<void> => {
const upsert_expense = toUpsertExpense({
new_expense: normalizeObject(expenses_store.current_expense, expense_validation_schema),
});
await expenses_store.upsertOrDeleteExpensesByEmployeeEmail(employee_email, date, upsert_expense);
}; };
if(typeof exp.amount === 'number') out.amount = exp.amount;
if(typeof exp.mileage === 'number') out.mileage = exp.mileage;
return out;
}
//GET by email, year and period no const updateExpenseByEmployeeEmail = async (employee_email: string, date: string): Promise<void> => {
export const getPayPeriodExpenses = async ( const upsert_expense = toUpsertExpense({
email: string, old_expense: normalizeObject(expenses_store.initial_expense, expense_validation_schema),
pay_year: number, new_expense: normalizeObject(expenses_store.current_expense, expense_validation_schema),
pay_period_no: number
) : Promise<PayPeriodExpenses> => {
const encoded_email = encodeURIComponent(email);
const encoded_year = encodeURIComponent(String(pay_year));
const encoded_pay_period_no = encodeURIComponent(String(pay_period_no));
try {
const { data } = await api.get<PayPeriodExpenses>(`/expenses/${encoded_email}/${encoded_year}/${encoded_pay_period_no}`);
const items = Array.isArray(data.expenses) ? data.expenses.map(normalizeExpense) : [];
return {
...data,
expenses: items,
};
} catch(err:any) {
const status_code: number = err?.response?.status ?? 500;
const data = err?.response?.data ?? {};
throw new ExpensesApiError({
status_code,
error_code: data.error_code,
message: data.message || data.error || err.message,
context: data.context,
}); });
} await expenses_store.upsertOrDeleteExpensesByEmployeeEmail(employee_email, date, upsert_expense);
}; };
//PUT by email, year and period no const deleteExpenseByEmployeeEmail = async (employee_email: string, date: string): Promise<void> => {
export const putPayPeriodExpenses = async ( const upsert_expense = toUpsertExpense({
email: string, old_expense: normalizeObject(expenses_store.initial_expense, expense_validation_schema),
pay_year: number,
pay_period_no: number,
expenses: TimesheetExpense[]
): Promise<PayPeriodExpenses> => {
const encoded_email = encodeURIComponent(email);
const encoded_year = encodeURIComponent(String(pay_year));
const encoded_pay_period_no = encodeURIComponent(String(pay_period_no));
const plain = Array.isArray(expenses) ? expenses.map(toPlain): [];
const normalized: ExpensePayload[] = plain.map((exp) => {
const norm = normalizeExpense(exp as TimesheetExpense);
validateExpenseUI(norm, 'expense_item');
return normalizePayload(norm as unknown as ExpensePayload);
});
const body: UpsertExpensesBody = {expenses: normalized};
try {
const { data } = await api.put<UpsertExpensesResponse>(
`/expenses/${encoded_email}/${encoded_year}/${encoded_pay_period_no}`,
body,
{ headers: {'Content-Type': 'application/json'}}
);
const items = Array.isArray(data?.data?.expenses)
? data.data.expenses.map(normalizeExpense)
: [];
return {
...(data?.data ?? {
pay_period_no,
pay_year,
employee_email: email,
is_approved: false,
expenses: [],
totals: {amount: 0, mileage: 0},
}),
expenses: items,
};
} catch (err: any) {
const status_code: number = err?.response?.status ?? 500;
const data = err?.response?.data ?? {};
throw new ExpensesApiError({
status_code,
error_code: data.error_code,
message: data.message || data.error || err.message,
context: data.context,
}); });
} await expenses_store.upsertOrDeleteExpensesByEmployeeEmail(employee_email, date, upsert_expense);
}; };
export const postPayPeriodExpenses = async (
email: string,
pay_year: number,
pay_period_no: number,
new_expenses: TimesheetExpense[]
): Promise<PayPeriodExpenses> => {
const encoded_email = encodeURIComponent(email);
const encoded_year = encodeURIComponent(String(pay_year));
const encoded_pp = encodeURIComponent(String(pay_period_no));
const plain = Array.isArray(new_expenses) ? new_expenses.map(toPlain) : [];
const normalized: ExpensePayload[] = plain.map((exp) => {
const norm = normalizeExpense(exp as TimesheetExpense);
validateExpenseUI(norm, 'expense_item');
return normalizePayload(norm as unknown as ExpensePayload);
});
const body: UpsertExpensesBody = { expenses: normalized };
try {
const { data } = await api.post<UpsertExpensesResponse>(
`/expenses/${encoded_email}/${encoded_year}/${encoded_pp}`,
body,
{ headers: { 'content-type': 'application/json' } }
);
const items = Array.isArray(data?.data?.expenses)
? data.data.expenses.map(normalizeExpense)
: [];
return { return {
...(data?.data ?? { createExpenseByEmployeeEmail,
pay_period_no, updateExpenseByEmployeeEmail,
pay_year, deleteExpenseByEmployeeEmail,
employee_email: email,
is_approved: false,
expenses: [],
totals: { amount: 0, mileage: 0 },
}),
expenses: items,
}; };
} catch (err: any) {
const status_code: number = err?.response?.status ?? 500;
const data = err?.response?.data ?? {};
throw new ExpensesApiError({
status_code,
error_code: data.error_code,
message: data.message || data.error || err.message,
context: data.context,
});
}
};
const resolveDateISO = (a?: ExpensePayload, b?: ExpensePayload): string => {
const d = a?.date || b?.date;
if(!d) throw new Error('date is required in payload');
return d;
};
const makeBody = (obj: {
old_expense?: ExpensePayload;
new_expense?: ExpensePayload;
}) => obj;
const postUpsert = async (email: string, date: string, body: {
old_expense?: ExpensePayload;
new_expense?: ExpensePayload;
}): Promise<UpsertExpenseResult> => {
try {
const url = `/expenses/upsert/${encodeURIComponent(email)}/${date}`;
const { data } = await api.post<UpsertExpenseResult>(url, body, {
headers: { 'Content-Type': 'application/json'},
});
return data;
} catch(err:any) {
const status_code: number = err?.response?.status ?? 500;
const data = err?.response?.data ?? {};
throw new ExpensesApiError({
status_code,
error_code: data.error_code,
message: data.message || data.error || err.message,
context: data.context,
});
}
};
//create a new expense
export const createExpenseByDate = async ( email: string, payload: ExpensePayload): Promise<UpsertExpenseResult> => {
const new_expense = normalizePayload(payload);
const date = resolveDateISO(new_expense);
return postUpsert(email, date, makeBody({ new_expense: new_expense }));
};
//update an expense
export const updateExpenseByDate = async ( email: string, old_expense: ExpensePayload, new_expense: ExpensePayload): Promise<UpsertExpenseResult> => {
const old_exp = normalizePayload(old_expense);
const new_exp = normalizePayload(new_expense);
const date = resolveDateISO(new_exp, old_exp);
return postUpsert(email, date, makeBody({ old_expense: old_exp,new_expense: new_exp }));
};
//delete an expense
export const deleteExpenseByDate = async (email: string, old_expense: ExpensePayload): Promise<UpsertExpenseResult> => {
const old = normalizePayload(old_expense);
const date = resolveDateISO(undefined, old);
return postUpsert(email, date, makeBody({ old_expense: old }));
}; };

View File

@ -1,141 +1,85 @@
import { api } from "src/boot/axios"; import { unwrapAndClone } from "src/utils/unwrap-and-clone";
import { isProxy, toRaw } from "vue"; import { TIME_FORMAT_PATTERN } from "src/modules/timesheets/constants/shift.constants";
import { DATE_FORMAT_PATTERN, TIME_FORMAT_PATTERN } from "../../constants/shift.constants"; import { GenericApiError } from "src/modules/timesheets/models/expense.validation";
import type { ShiftPayload } from "../../types/shift.types"; import { useShiftStore } from "src/stores/shift-store";
import type { UpsertShiftsBody, UpsertShiftsResponse } from "../../types/shift.interfaces"; import { default_shift, type Shift, type UpsertShift } from "src/modules/timesheets/models/shift.models";
/* eslint-disable */ import { deepEqual } from "src/utils/deep-equal";
//normalize payload to match backend data export const useShiftApi = () => {
export const normalize_comment = (input?: string): string | undefined => { const shift_store = useShiftStore();
if ( typeof input === 'undefined' || input === null) return undefined;
const trimmed = String(input).trim();
return trimmed.length ? trimmed : undefined;
}
export const normalize_payload = (payload: ShiftPayload): ShiftPayload => { const normalizeShiftPayload = (shift: Shift): Shift => {
const comment = normalize_comment(payload.comment); const comment = shift.comment?.trim() || undefined;
return {
start_time: payload.start_time,
end_time: payload.end_time,
type: payload.type,
is_remote: Boolean(payload.is_remote),
...(comment !== undefined ? { comment } : {}),
};
};
const toPlain = <T extends object>(obj: T): T => { return {
const raw = isProxy(obj) ? toRaw(obj): obj; date: shift.date,
if(typeof (globalThis as any).structuredClone === 'function') { start_time: shift.start_time,
return (globalThis as any).structuredClone(raw); end_time: shift.end_time,
} type: shift.type,
return JSON.parse(JSON.stringify(raw)); is_approved: false,
} is_remote: shift.is_remote,
comment: comment,
//error handling
export interface ApiErrorPayload {
status_code: number;
error_code?: string;
message?: string;
context?: Record<string, unknown>;
}
export class UpsertShiftsError extends Error {
status_code: number;
error_code?: string | undefined;
context?: Record<string, unknown> | undefined;
constructor(payload: ApiErrorPayload) {
super(payload.message || 'Request failed');
this.name = 'UpsertShiftsError';
this.status_code = payload.status_code;
this.error_code = payload.error_code;
this.context = payload.context;
}
}
const parseHHMM = (s:string): [number, number] => {
const m = /^(\d{2}):(\d{2})$/.exec(s);
if(!m) {
throw new UpsertShiftsError({ status_code: 400, message: `Invalid time format: ${s}. Expected HH:MM.`});
}
const h = Number(m[1]);
const min = Number(m[2]);
if(Number.isNaN(h) || Number.isNaN(min) || h < 0 || h> 23 || min < 0 || min > 59) {
throw new UpsertShiftsError({ status_code: 400, message: `Invalid time value: ${s}.`})
}
return [h, min];
}
const toMinutes = (hhmm: string): number => {
const [h,m] = parseHHMM(hhmm);
return h * 60 + m;
}
const validateShift = (payload: ShiftPayload, label: 'old_shift'|'new_shift') => {
if(!TIME_FORMAT_PATTERN.test(payload.start_time) || !TIME_FORMAT_PATTERN.test(payload.end_time)) {
throw new UpsertShiftsError({
status_code: 400,
message: `Invalid time format in ${label}. Expected HH:MM`,
context: { [label]: payload }
});
}
if(toMinutes(payload.end_time) <= toMinutes(payload.start_time)) {
throw new UpsertShiftsError({
status_code: 400,
message: `Invalid time range in ${label}. The End time must be after the Start time`,
context: { [label]: payload}
});
}
}
export const upsertShiftsByDate = async (
email: string,
date: string,
body: UpsertShiftsBody,
): Promise<UpsertShiftsResponse> => {
if (!DATE_FORMAT_PATTERN.test(date)){
throw new UpsertShiftsError({
status_code: 400,
message: 'Invalid date format, expected YYYY-MM-DD',
});
}
const flatBody: UpsertShiftsBody = {
...(body.old_shift ? { old_shift: toPlain(body.old_shift) }: {}),
...(body.new_shift ? { new_shift: toPlain(body.new_shift) }: {}),
};
const normalized: UpsertShiftsBody = {
...(flatBody.old_shift ? { old_shift: normalize_payload(flatBody.old_shift) } : {}),
...(flatBody.new_shift ? { new_shift: normalize_payload(flatBody.new_shift) } : {}),
};
if(normalized.old_shift) validateShift(normalized.old_shift, 'old_shift');
if(normalized.new_shift) validateShift(normalized.new_shift, 'new_shift');
const encoded_email = encodeURIComponent(email);
const encoded_date = encodeURIComponent(date);
//error handling to be used with notify in case of bad input
try {
const { data } = await api.put<UpsertShiftsResponse>(
`/shifts/upsert/${encoded_email}/${encoded_date}`,
normalized,
{ headers: {'content-type': 'application/json'}}
);
return data;
} catch (err: any) {
const status_code: number = err?.response?.status ?? 500;
const data = err?.response?.data ?? {};
const payload: ApiErrorPayload = {
status_code,
error_code: data.error_code,
message: data.message || data.error || err.message,
context: data.context,
}; };
throw new UpsertShiftsError(payload); };
}
}; const parseHHMM = (s: string): [number, number] => {
const m = /^(\d{2}):(\d{2})$/.exec(s);
if (!m) {
throw new GenericApiError({ status_code: 400, message: `Invalid time format: ${s}. Expected HH:MM.` });
}
const h = Number(m[1]);
const min = Number(m[2]);
if (Number.isNaN(h) || Number.isNaN(min) || h < 0 || h > 23 || min < 0 || min > 59) {
throw new GenericApiError({ status_code: 400, message: `Invalid time value: ${s}.` })
}
return [h, min];
};
const toMinutes = (hhmm: string): number => {
const [h, m] = parseHHMM(hhmm);
return h * 60 + m;
};
const validateShift = (shift: Shift, label: 'old_shift' | 'new_shift') => {
if (!TIME_FORMAT_PATTERN.test(shift.start_time) || !TIME_FORMAT_PATTERN.test(shift.end_time)) {
throw new GenericApiError({
status_code: 400,
message: `Invalid time format in ${label}. Expected HH:MM`,
context: { [label]: shift }
});
}
if (toMinutes(shift.end_time) <= toMinutes(shift.start_time)) {
throw new GenericApiError({
status_code: 400,
message: `Invalid time range in ${label}. The End time must be after the Start time`,
context: { [label]: shift }
});
}
};
const upsertOrDeleteShiftByEmployeeEmail = async (employee_email: string): Promise<void> => {
const flat_upsert_shift: UpsertShift = {
...(deepEqual(shift_store.initial_shift, default_shift) ? { old_shift: unwrapAndClone(shift_store.initial_shift) } : {}),
...(deepEqual(shift_store.current_shift, default_shift) ? { new_shift: unwrapAndClone(shift_store.current_shift) } : {}),
};
const normalized_upsert_shift: UpsertShift = {
...(flat_upsert_shift.old_shift ? { old_shift: normalizeShiftPayload(flat_upsert_shift.old_shift) } : {}),
...(flat_upsert_shift.new_shift ? { new_shift: normalizeShiftPayload(flat_upsert_shift.new_shift) } : {}),
};
if (normalized_upsert_shift.old_shift) validateShift(normalized_upsert_shift.old_shift, 'old_shift');
if (normalized_upsert_shift.new_shift) validateShift(normalized_upsert_shift.new_shift, 'new_shift');
await shift_store.upsertOrDeleteShiftByEmployeeEmail(employee_email, normalized_upsert_shift);
};
return {
upsertOrDeleteShiftByEmployeeEmail,
};
}

View File

@ -1,58 +1,53 @@
import { useAuthStore } from "src/stores/auth-store"; import { useAuthStore } from "src/stores/auth-store";
import { useTimesheetStore } from "src/stores/timesheet-store" import { useTimesheetStore } from "src/stores/timesheet-store"
/* eslint-disable */
export const useTimesheetApi = () => { export const useTimesheetApi = () => {
const timesheet_store = useTimesheetStore(); const timesheet_store = useTimesheetStore();
const auth_store = useAuthStore(); const auth_store = useAuthStore();
const NEXT = 1;
const PREVIOUS = -1;
const getPayPeriodDetailsByDate = async (date_string: string, employee_email?: string) => {
const success = await timesheet_store.getPayPeriodByDateOrYearAndNumber(date_string);
const getTimesheetsByDate = async (date_string: string) => {
const success = await timesheet_store.getPayPeriodByDate(date_string);
if (success) { if (success) {
await timesheet_store.getTimesheetsByPayPeriodAndEmail(auth_store.user.email) await timesheet_store.getPayPeriodDetailsByEmployeeEmail(employee_email ?? auth_store.user.email)
} }
} }
const fetchPayPeriod = async (direction: number) => { const getNextOrPreviousPayPeriodDetails = async (direction: number, employee_email?: string) => {
const current_pay_period = timesheet_store.current_pay_period; const { pay_period } = timesheet_store;
let new_pay_period_no = current_pay_period.pay_period_no + direction; let new_number = pay_period.pay_period_no + direction;
let new_pay_year = current_pay_period.pay_year; let new_year = pay_period.pay_year;
if (new_pay_period_no > 26) { if (new_number > 26) {
new_pay_period_no = 1; new_number = 1;
new_pay_year += 1; new_year += 1;
} }
if (new_pay_period_no < 1) { if (new_number < 1) {
new_pay_period_no = 26; new_number = 26;
new_pay_year -= 1; new_year -= 1;
} }
const success = await timesheet_store.getPayPeriodByYearAndPeriodNumber(new_pay_year, new_pay_period_no); const success = await timesheet_store.getPayPeriodByDateOrYearAndNumber(new_year, new_number);
if (success) { if (success) {
await timesheet_store.getTimesheetsByPayPeriodAndEmail(auth_store.user.email); await timesheet_store.getPayPeriodDetailsByEmployeeEmail(employee_email ?? auth_store.user.email);
} }
}; };
const getNextPayPeriodDetails = async (employee_email?: string) => {
await getNextOrPreviousPayPeriodDetails(NEXT, employee_email ?? auth_store.user.email);
}
const getNextPayPeriod = async () => fetchPayPeriod(1); const getPreviousPayPeriodDetails = async (employee_email?: string) => {
const getPreviousPayPeriod = async () => fetchPayPeriod(-1); await getNextOrPreviousPayPeriodDetails(PREVIOUS, employee_email ?? auth_store.user.email);
}
const getPreviousPeriodForUser = async (_employee_email: string) => { return {
await getPreviousPayPeriod(); getPayPeriodDetailsByDate,
}; getNextPayPeriodDetails,
getPreviousPayPeriodDetails,
const getNextPeriodForUser = async (_employee_email: string) => {
await getNextPayPeriod();
};
return {
getTimesheetsByDate,
fetchPayPeriod,
// getCurrentPayPeriod,
getNextPayPeriod,
getPreviousPayPeriod,
getPreviousPeriodForUser,
getNextPeriodForUser,
}; };
}; };

View File

@ -1,11 +1,11 @@
import { ref, computed } from "vue"; import { ref, computed } from "vue";
import type { TimesheetExpense } from "../types/expense.interfaces"; import type { Expense } from "src/modules/timesheets/models/expense.models";
import type { ExpenseType } from "../types/expense.types"; import type { ExpenseType } from "src/modules/timesheets/models/expense.models";
export const useExpenseDraft = (initial?: Partial<TimesheetExpense>) => { export const useExpenseDraft = (initial?: Partial<Expense>) => {
const DEFAULT_TYPE: ExpenseType = 'EXPENSES'; const DEFAULT_TYPE: ExpenseType = 'EXPENSES';
const draft = ref<Partial<TimesheetExpense>>({ const draft = ref<Partial<Expense>>({
date: '', date: '',
type: DEFAULT_TYPE, type: DEFAULT_TYPE,
comment: '', comment: '',

View File

@ -1,55 +1,59 @@
import { ref, type Ref } from "vue"; // import { ref, type Ref } from "vue";
import { normalizeExpense, validateExpenseUI } from "../utils/expenses-validators"; // import { normalizeObject } from "src/utils/normalize-object";
import { normExpenseType } from "../utils/expense.util"; // import { normExpenseType } from "../utils/expense.util";
import type { TimesheetExpense } from "../types/expense.interfaces"; // import type { Expense, PayPeriodExpenses } from "src/modules/timesheets/models/expense.models";
// import { useExpensesStore } from "src/stores/expense-store";
// import { unwrapAndClone } from "src/utils/unwrap-and-clone";
// import { expense_validation_schema } from "src/modules/timesheets/models/expense.validation";
type UseExpenseItemsParams = { // const expenses_store = useExpensesStore();
initial_expenses?: TimesheetExpense[] | null | undefined;
draft: Ref<Partial<TimesheetExpense>>;
is_approved: Ref<boolean> | boolean;
};
export const useExpenseItems = ({
initial_expenses,
draft,
is_approved
}: UseExpenseItemsParams) => {
const items = ref<TimesheetExpense[]>(
Array.isArray(initial_expenses) ? initial_expenses.map(normalizeExpense) : []
);
const addFromDraft = () => { // export const useExpenseItems = () => {
const candidate: TimesheetExpense = normalizeExpense({ // let expenses = unwrapAndClone(expenses_store.pay_period_expenses.expenses.map(normalizeExpense));
date: draft.value.date,
type: normExpenseType(draft.value.type),
...(typeof draft.value.amount === 'number' ? { amount: draft.value.amount }: {}),
...(typeof draft.value.mileage === 'number' ? { mileage: draft.value.mileage }: {}),
comment: String(draft.value.comment ?? '').trim(),
} as TimesheetExpense);
validateExpenseUI(candidate, 'expense_draft'); // const normalizePayload = (expense: Expense): Expense => {
items.value = [ ...items.value, candidate]; // const exp = normalizeObject(expense, expense_validation_schema);
}; // const out: Expense = {
// date: exp.date,
// type: exp.type as ExpenseType,
// comment: exp.comment || '',
// };
// if(typeof exp.amount === 'number') out.amount = exp.amount;
// if(typeof exp.mileage === 'number') out.mileage = exp.mileage;
// return out;
// }
const removeAt = (index: number) => { // const addFromDraft = () => {
const locked = typeof is_approved === 'boolean' ? is_approved : is_approved.value; // const candidate: Expense = normalizeExpense({
if(locked) return; // date: draft.date,
if(index < 0 || index >= items.value.length) return; // type: normExpenseType(draft.type),
items.value = items.value.filter((_,i)=> i !== index); // ...(typeof draft.amount === 'number' ? { amount: draft.amount }: {}),
}; // ...(typeof draft.mileage === 'number' ? { mileage: draft.mileage }: {}),
// comment: String(draft.comment ?? '').trim(),
// } as Expense);
const validateAll = () => { // validateExpenseUI(candidate, 'expense_draft');
for (const expense of items.value) { // expenses = [ ...expenses, candidate];
validateExpenseUI(expense, 'expense_item'); // };
}
};
const payload = () => items.value.map(normalizeExpense); // const removeAt = (index: number) => {
// if(index < 0 || index >= expenses.length) return;
// expenses = expenses.filter((_,i)=> i !== index);
// };
return { // const validateAll = () => {
items, // for (const expense of expenses) {
addFromDraft, // validateExpenseUI(expense, 'expense_item');
removeAt, // }
validateAll, // };
payload,
}; // const payload = () => expenses.map(normalizeExpense);
};
// return {
// expenses,
// addFromDraft,
// removeAt,
// validateAll,
// payload,
// };
// };

View File

@ -1,3 +1 @@
export const COMMENT_MAX_LENGTH = 280;
export const DATE_FORMAT_PATTERN = /^\d{4}-\d{2}-\d{2}$/; export const DATE_FORMAT_PATTERN = /^\d{4}-\d{2}-\d{2}$/;

View File

@ -0,0 +1,45 @@
export type ExpenseType = 'PER_DIEM' | 'MILEAGE' | 'EXPENSES' | 'PRIME_GARDE';
export const EXPENSE_TYPE: ExpenseType[] = ['PER_DIEM', 'MILEAGE', 'EXPENSES', 'PRIME_GARDE',];
export const TYPES_WITH_MILEAGE_ONLY: Readonly<ExpenseType[]> = ['MILEAGE'];
export const TYPES_WITH_AMOUNT_ONLY: Readonly<ExpenseType[]> = ['PER_DIEM', 'EXPENSES', 'PRIME_GARDE',];
export interface Expense {
date: string;
type: ExpenseType;
amount: number;
mileage?: number;
comment: string;
supervisor_comment?: string;
is_approved: boolean;
}
export type ExpenseTotals = {
amount: number;
mileage: number;
reimburseable_total?: number;
};
export interface PayPeriodExpenses {
is_approved: boolean;
expenses: Expense[];
totals?: ExpenseTotals;
}
export interface UpsertExpense {
old_expense: Expense;
new_expense: Expense;
}
export const default_expense: Expense = {
date: '',
type: 'EXPENSES',
amount: 0,
comment: '',
is_approved: false,
};
export const default_pay_period_expenses: PayPeriodExpenses = {
is_approved: false,
expenses: [],
}

View File

@ -0,0 +1,77 @@
import { type Expense, EXPENSE_TYPE, type ExpenseType } from "src/modules/timesheets/models/expense.models";
import type { Normalizer } from "src/utils/normalize-object";
export interface ApiErrorPayload {
status_code: number;
error_code?: string;
message?: string;
context?: Record<string, unknown>;
};
export abstract class ApiError extends Error {
status_code: number;
error_code?: string;
context?: Record<string, unknown>;
constructor(payload: ApiErrorPayload, defaultMessage: string) {
super(payload.message || defaultMessage);
this.status_code = payload.status_code;
this.error_code = payload.error_code ?? "unknown";
this.context = payload.context ?? {'unknown': 'unknown error has occured', };
}
};
export class GenericApiError extends ApiError {
constructor(payload: ApiErrorPayload) {
super(payload, 'Encountered an error processing request');
this.name = 'GenericApiError';
}
};
export class ExpensesValidationError extends ApiError {
constructor(payload: ApiErrorPayload) {
super(payload, 'Invalid expense payload');
this.name = 'ExpensesValidationError';
}
};
export class ExpensesApiError extends ApiError {
constructor(payload: ApiErrorPayload) {
super(payload, 'Request failed');
this.name = 'ExpensesApiError';
}
};
export const expense_validation_schema: Normalizer<Expense> = {
date: v => typeof v === 'string' ? v.trim() : '1970-01-01',
type: v => EXPENSE_TYPE.includes(v as ExpenseType) ? v as ExpenseType : "EXPENSES",
amount: v => typeof v === "number" ? v : -1,
mileage: v => typeof v === "number" ? v : undefined,
comment: v => typeof v === 'string' ? v.trim() : '',
supervisor_comment: v => typeof v === 'string' ? v.trim() : '',
is_approved: v => !!v,
};
export function toExpensesError(err: unknown): ExpensesValidationError | ExpensesApiError {
if (err instanceof ExpensesValidationError || err instanceof ExpensesApiError) {
return err;
}
if (typeof err === 'object' && err !== null && 'status_code' in err) {
const payload = err as ApiErrorPayload;
// Don't know how to differentiate both types of errors, can be updated here
if (payload.error_code?.startsWith('API_')) {
return new ExpensesApiError(payload);
}
return new ExpensesValidationError(payload);
}
// Fallback with ValidationError as default
return new ExpensesValidationError({
status_code: 500,
message: err instanceof Error ? err.message : 'Unknown error',
context: { original: err }
});
}

View File

@ -0,0 +1,78 @@
import type { Shift } from "./shift.models";
import { default_expense, type Expense } from "src/modules/timesheets/models/expense.models";
export type Week<T> = {
sun: T;
mon: T;
tue: T;
wed: T;
thu: T;
fri: T;
sat: T;
};
export interface PayPeriodDetails {
weeks: PayPeriodDetailsWeek[];
employee_full_name: string;
}
export interface PayPeriodDetailsWeek {
is_approved: boolean;
shifts: Week<PayPeriodDetailsWeekDayShifts>
expenses: Week<PayPeriodDetailsWeekDayExpenses>;
}
export interface PayPeriodDetailsWeekDayShifts {
shifts: Shift[];
regular_hours: number;
evening_hours: number;
emergency_hours: number;
overtime_hours: number;
total_hours: number;
short_date: string;
break_duration?: number;
}
export interface PayPeriodDetailsWeekDayExpenses {
expenses: Expense[];
total_expenses: number;
total_mileage: number;
}
const makeWeek = <T>(factory: ()=> T): Week<T> => ({
sun: factory(),
mon: factory(),
tue: factory(),
wed: factory(),
thu: factory(),
fri: factory(),
sat: factory(),
});
const emptyDailySchedule = (): PayPeriodDetailsWeekDayShifts => ({
shifts: [],
regular_hours: 0,
evening_hours: 0,
emergency_hours: 0,
overtime_hours: 0,
total_hours: 0,
short_date: "",
break_duration: 0,
});
const emptyDailyExpenses = (): PayPeriodDetailsWeekDayExpenses => ({
expenses: [default_expense,],
total_expenses: -1,
total_mileage: -1,
});
export const defaultPayPeriodDetailsWeek = (): PayPeriodDetailsWeek => ({
is_approved: false,
shifts: makeWeek(emptyDailySchedule),
expenses: makeWeek(emptyDailyExpenses),
});
export const default_pay_period_details: PayPeriodDetails = {
weeks: [ defaultPayPeriodDetailsWeek(), ],
employee_full_name: "",
}

View File

@ -0,0 +1,50 @@
export const SHIFT_TYPES = [
'REGULAR',
'EVENING',
'EMERGENCY',
'OVERTIME',
'HOLIDAY',
'VACATION',
'SICK'
];
export type ShiftType = 'REGULAR' | 'EVENING' | 'EMERGENCY' | 'OVERTIME' | 'HOLIDAY' | 'VACATION' | 'SICK' ;
export type UpsertAction = 'create' | 'update' | 'delete';
export type ShiftLegendItem = {
type: ShiftType;
color: string;
label_type: string;
text_color?: string;
};
export interface Shift {
date: string;
type: ShiftType;
start_time: string;
end_time: string;
comment: string | undefined;
is_approved: boolean;
is_remote: boolean;
}
export interface UpsertShiftsResponse {
action: UpsertAction;
day: Shift[];
}
export interface UpsertShift {
old_shift?: Shift | undefined;
new_shift?: Shift | undefined;
}
export const default_shift: Readonly<Shift> = {
date: '',
start_time: '--:--',
end_time: '--:--',
type: 'REGULAR',
comment: '',
is_approved: false,
is_remote: false,
};

View File

@ -1,5 +1,3 @@
export type FormMode = 'create' | 'edit' | 'delete';
export type PayPeriodLabel = { export type PayPeriodLabel = {
start_date: string; start_date: string;
end_date: string; end_date: string;

View File

@ -1,188 +0,0 @@
<script setup lang="ts">
import { date } from 'quasar';
import { computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAuthStore } from 'src/stores/auth-store';
import { useExpensesStore } from 'src/stores/expense-store';
import { useShiftStore } from 'src/stores/shift-store';
import { useTimesheetStore } from 'src/stores/timesheet-store';
import { useTimesheetApi } from '../composables/api/use-timesheet-api';
import { buildShiftOptions } from '../utils/shift.util';
import { formatPayPeriodLabel } from '../utils/timesheet-format.util';
import TimesheetNavigation from '../components/timesheet/timesheet-navigation.vue';
import ShiftsLegend from '../components/shift/shifts-legend.vue';
import ShiftCrudDialog from '../components/shift/shift-crud-dialog.vue';
import TimesheetDetailsExpenses from '../components/expenses/timesheet-details-expenses.vue';
import { SHIFT_KEY } from '../types/shift.types';
import type { TimesheetExpense } from '../types/expense.interfaces';
import DetailedShiftList from '../components/shift/detailed-shift-list.vue';
/* eslint-disable */
//------------------- stores -------------------
const { locale, t } = useI18n();
const auth_store = useAuthStore();
const expenses_store = useExpensesStore();
const shift_store = useShiftStore();
const timesheet_store = useTimesheetStore();
const timesheet_api = useTimesheetApi();
//------------------- expenses -------------------
const openExpensesDialog = () => expenses_store.openDialog({
email: auth_store.user.email,
pay_year: timesheet_store.current_pay_period.pay_year,
pay_period_no: timesheet_store.current_pay_period.pay_period_no,
t,
});
const onSaveExpenses = async ( payload: { email: string; pay_year: number; pay_period_no: number; expenses: TimesheetExpense[] }) => {
await expenses_store.saveExpenses({...payload, t});
await timesheet_store.refreshCurrentPeriodForUser(auth_store.user.email);
};
const onCloseExpenses = () => expenses_store.closeDialog();
//------------------- pay-period format label -------------------
const date_options: Intl.DateTimeFormatOptions = { day: 'numeric', month: 'long', year: 'numeric' };
const pay_period_label = computed(() => formatPayPeriodLabel(
timesheet_store.current_pay_period?.label,
locale.value,
date.extractDate,
date_options
)
);
//------------------- q-select Shift options -------------------
const shift_options = computed(() => buildShiftOptions(SHIFT_KEY, t));
//------------------- navigation by date -------------------
const onDateSelected = async (date_string: string) => {
await timesheet_store.loadByIsoDate(date_string, auth_store.user.email);
};
onMounted(async () => {
await timesheet_store.loadToday(auth_store.user.email);
});
// ------------------- shifts -------------------
const onRequestAdd = ({ date }: { date: string }) => shift_store.openCreate(date);
const onRequestEdit = ({ date, shift }: { date: string; shift: any }) => shift_store.openEdit(date, shift);
const onRequestDelete = async ({ date, shift }: { date: string; shift: any }) => shift_store.openDelete(date, shift);
const onShiftSaved = async () => {
await timesheet_store.refreshCurrentPeriodForUser(auth_store.user.email);
};
</script>
<template>
<q-page padding class="q-pa-md bg-secondary" >
<!-- title and dates -->
<div class="text-h4 row justify-center text-center q-mt-lg text-uppercase text-weight-bolder text-grey-8">
{{ $t('timesheet.title') }}
</div>
<div class="row items-center justify-center q-py-none q-my-none">
<div
class="text-primary text-uppercase text-weight-bold"
:class="$q.screen.lt.md ? '' : 'text-h6'"
>
{{ pay_period_label.start_date }}
</div>
<div
class="text-grey-8 text-uppercase q-mx-md"
:class="$q.screen.lt.md ? 'text-weight-medium text-caption' : 'text-weight-bold'"
>
{{ $t('timesheet.date_ranges_to') }}
</div>
<div
class="text-primary text-uppercase text-center text-weight-bold"
:class="$q.screen.lt.md ? '' : 'text-h6'"
>
{{ pay_period_label.end_date }}
</div>
</div>
<div>
<q-card flat class=" col q-mt-md bg-secondary">
<!-- navigation btn -->
<q-card-section horizontal>
<q-btn
color="primary"
unelevated
icon="receipt_long"
:label="$t('timesheet.expense.open_btn')"
@click="openExpensesDialog"
/>
</q-card-section>
<q-card-section class="row items-center justify-between q-px-md q-pb-none">
<TimesheetNavigation
:is-disabled="timesheet_store.is_loading"
:is-previous-limit="timesheet_store.is_calendar_limit"
@date-selected="onDateSelected"
@pressed-previous-button="timesheet_api.getPreviousPeriodForUser(auth_store.user.email)"
@pressed-next-button="timesheet_api.getNextPeriodForUser(auth_store.user.email)"
/>
</q-card-section>
<!-- shift's colored legend -->
<ShiftsLegend
:is-loading="false"
/>
<q-card-section horizontal>
<!-- display of shifts for 2 timesheets -->
<DetailedShiftList
:raw-data="timesheet_store.pay_period_employee_details"
:current-pay-period="timesheet_store.current_pay_period"
@request-add="onRequestAdd"
@request-edit="onRequestEdit"
@request-delete="onRequestDelete"
/>
<q-inner-loading :showing="timesheet_store.is_loading" color="primary"/>
</q-card-section>
</q-card>
</div>
<!-- read/edit/create/delete expense dialog -->
<q-dialog
v-model="expenses_store.is_dialog_open"
persistent
>
<q-card
class="q-pa-md column"
style=" min-width: 70vw;"
>
<q-inner-loading :showing="expenses_store.is_loading">
<q-spinner size="32px"/>
</q-inner-loading>
<!-- <q-banner
v-if="expenses_error"
dense
class="bg-red-2 col-auto text-negative q-mt-sm"
>
{{ expenses_error }}
</q-banner> -->
<TimesheetDetailsExpenses
v-if="expenses_store.data"
:pay_period_no="expenses_store.data.pay_period_no"
:pay_year="expenses_store.data.pay_year"
:email="expenses_store.data.employee_email"
:is_approved="expenses_store.data.is_approved"
:initial_expenses="expenses_store.data.expenses"
@save="onSaveExpenses"
@close="onCloseExpenses"
@error=" "
/>
</q-card>
</q-dialog>
<!-- shift crud dialog -->
<ShiftCrudDialog
v-model="shift_store.is_open"
:mode="shift_store.mode"
:date-iso="shift_store.date_iso"
:email="auth_store.user.email"
:initial-shift="shift_store.initial_shift"
:shift-options="shift_options"
@close="shift_store.close"
@saved="onShiftSaved"
/>
</q-page>
</template>

View File

@ -0,0 +1,50 @@
import { api } from "src/boot/axios";
import type { UpsertShift } from "src/modules/timesheets/models/shift.models";
import type { PayPeriod } from "src/modules/shared/models/pay-period.models";
import type { PayPeriodDetails } from "src/modules/timesheets/models/pay-period-details.models";
import type { PayPeriodOverview } from "src/modules/timesheet-approval/models/pay-period-overview.models";
import type { Expense, PayPeriodExpenses, UpsertExpense } from "src/modules/timesheets/models/expense.models";
export const timesheetService = {
getPayPeriodDetailsByEmployeeEmail: async (email: string): Promise<PayPeriodDetails> => {
const response = await api.get(`/timesheets/${encodeURIComponent(email)}`);
return response.data;
},
getPayPeriodByDate: async (date_string: string): Promise<PayPeriod> => {
const response = await api.get(`pay-periods/date/${date_string}`);
return response.data;
},
getPayPeriodByYearAndPeriodNumber: async (year: number, period_number: number): Promise<PayPeriod> => {
const response = await api.get(`pay-periods/${year}/${period_number}`);
return response.data;
},
getPayPeriodOverviewsBySupervisorEmail: async (year: number, period_number: number, supervisor_email: string): Promise<PayPeriodOverview[]> => {
const response = await api.get(`pay-periods/${year}/${period_number}/${supervisor_email}`);
return response.data;
},
getPayPeriodDetailsByPayPeriodAndEmployeeEmail: async (year: number, period_no: number, email: string): Promise<PayPeriodDetails> => {
const response = await api.get('timesheets', { params: { year, period_no, email, } });
return response.data;
},
getExpensesByPayPeriodAndEmployeeEmail: async (email: string, year: string, period_number: string): Promise<PayPeriodExpenses> => {
const response = await api.get(`/expenses/${email}/${year}/${period_number}`);
return response.data;
},
upsertOrDeleteShiftsByDateAndEmployeeEmail: async (email: string, payload: UpsertShift[], date: string): Promise<PayPeriodDetails> => {
const response = await api.put(`/shifts/upsert/${email}/${date}`, payload);
return response.data;
},
upsertOrDeleteExpensesByPayPeriodAndEmployeeEmail: async (email: string, date: string, payload: UpsertExpense): Promise<Expense[]> => {
const headers = { 'Content-Type': 'application/json' }
const response = await api.put(`/expenses/upsert/${email}/${date}`, payload, { headers });
return response.data;
}
};

View File

@ -1,51 +0,0 @@
import { api } from "src/boot/axios";
import type { PayPeriod } from "src/modules/shared/types/pay-period-interface";
import type { PayPeriodEmployeeDetails } from "src/modules/timesheet-approval/types/timesheet-approval-pay-period-employee-details-interface";
import type { PayPeriodOverview } from "src/modules/timesheet-approval/types/timesheet-approval-pay-period-overview-interface";
import type { PayPeriodReportFilters } from "src/modules/timesheet-approval/types/timesheet-approval-pay-period-report-interface";
import type { Timesheet } from "../types/timesheet.interfaces";
import type { CreateShiftPayload, CreateWeekShiftPayload } from "../types/shift.interfaces";
export const timesheetTempService = {
//GET
getTimesheetsByEmail: async ( email: string, offset = 0): Promise<Timesheet> => {
const response = await api.get(`/timesheets/${encodeURIComponent(email)}`, {params: offset ? { offset } : undefined});
return response.data as Timesheet;
},
//POST
createTimesheetShifts: async ( email: string, shifts: CreateShiftPayload[], offset = 0): Promise<Timesheet> => {
const payload: CreateWeekShiftPayload = { shifts };
const response = await api.post(`/timesheets/shifts/${encodeURIComponent(email)}`, payload, { params: offset ? { offset }: undefined });
return response.data as Timesheet;
},
getPayPeriodByDate: async (date_string: string): Promise<PayPeriod> => {
const response = await api.get(`pay-periods/date/${date_string}`);
return response.data;
},
getPayPeriodByYearAndPeriodNumber: async (year: number, period_number: number): Promise<PayPeriod> => {
const response = await api.get(`pay-periods/${year}/${period_number}`);
return response.data;
},
getPayPeriodEmployeeOverviews: async (year: number, period_number: number, supervisor_email: string): Promise<PayPeriodOverview> => {
// TODO: REMOVE MOCK DATA PEFORE PUSHING TO PROD
const response = await api.get(`pay-periods/${year}/${period_number}/${supervisor_email}`);
console.log('pay period data: ', response.data);
return response.data;
},
getTimesheetsByPayPeriodAndEmail: async (year: number, period_no: number, email: string): Promise<PayPeriodEmployeeDetails> => {
const response = await api.get('timesheets', { params: { year, period_no, email, }});
console.log('employee details: ', response.data);
return response.data;
},
getTimesheetApprovalCSVReport: async (year: number, period_number: number, report_filters?: PayPeriodReportFilters) => {
const response = await api.get(`csv/${year}/${period_number}`, { params: { report_filters, }});
return response.data;
},
};

View File

@ -1,34 +0,0 @@
export interface ApiErrorPayload {
status_code: number;
error_code?: string;
message?: string;
context?: Record<string, unknown>;
}
export class ExpensesValidationError extends Error {
status_code: number;
error_code?: string | undefined;
context?: Record<string, unknown> | undefined;
constructor(payload: ApiErrorPayload) {
super(payload.message || 'Invalid expense payload');
this.name = 'ExpensesValidationError';
this.status_code = payload.status_code;
this.error_code = payload.error_code;
this.context = payload.context;
}
}
export class ExpensesApiError extends Error {
status_code: number;
error_code?: string;
context?: Record<string, unknown>;
constructor(payload: ApiErrorPayload) {
super(payload.message || 'Request failed');
this.name = 'ExpensesApiError';
this.status_code = payload.status_code;
if(payload.error_code !== undefined) this.error_code = payload.error_code;
if(payload.context !== undefined) this.context = payload.context;
}
}

View File

@ -1,63 +0,0 @@
import type { ExpenseType } from "./expense.types";
export interface Expense {
is_approved: boolean;
comment: string;
amount: number;
supervisor_comment: string;
}
export interface TimesheetExpense {
date: string;
type: string;
amount?: number;
mileage?: number;
comment?: string;
supervisor_comment?: string;
is_approved?: boolean;
}
export interface PayPeriodExpenses {
pay_period_no: number;
pay_year: number;
employee_email: string;
is_approved: boolean;
expenses: TimesheetExpense[];
totals: {
amount: number;
mileage: number;
reimbursable_total?: number;
}
}
//used by expenses form, either amount or mileage, not both will be sent to the backend
export interface ExpensePayload{
date: string;
type: ExpenseType;
amount?: number;
mileage?: number;
comment: string;
}
//amount is required since mileage is returned in $ ( km * modifier )
export interface ExpenseDay{
date: string;
type: ExpenseType;
amount: number;
mileage?: number;
comment: string;
is_approved: boolean;
}
export interface UpsertExpensesBody {
expenses: ExpensePayload[];
}
export interface UpsertExpensesResponse {
data: PayPeriodExpenses;
}
export interface UpsertExpenseResult {
action: 'created' | 'updated' | 'deleted';
day: ExpenseDay[];
}

View File

@ -1,29 +0,0 @@
import type { TimesheetExpense } from "./expense.interfaces";
export const EXPENSE_TYPE = [
'PER_DIEM',
'MILEAGE',
'EXPENSES',
'PRIME_GARDE',
] as const;
export type ExpenseType = (typeof EXPENSE_TYPE)[number];
export const TYPES_WITH_MILEAGE_ONLY: Readonly<ExpenseType[]> = ['MILEAGE'];
export const TYPES_WITH_AMOUNT_ONLY: Readonly<ExpenseType[]> = [
'PER_DIEM',
'EXPENSES',
'PRIME_GARDE',
];
export type ExpenseTotals = {
amount: number;
mileage: number;
};
export type ExpenseSavePayload = {
pay_period_no: number;
pay_year: number;
email: string;
expenses: TimesheetExpense[];
};

View File

@ -1,11 +0,0 @@
import type { Shift } from "./shift.interfaces";
export const default_shift: Readonly<Shift> = {
date: '',
start_time: '--:--',
end_time: '--:--',
type:'REGULAR',
comment: '',
is_approved: false,
is_remote: false,
};

View File

@ -1,45 +0,0 @@
import type { ShiftKey, ShiftPayload } from "./shift.types";
import type { UpsertAction } from "./ui.types";
export interface Shift {
date: string;
type: ShiftKey;
start_time: string;
end_time: string;
comment: string;
is_approved: boolean;
is_remote: boolean;
}
export interface CreateShiftPayload {
date: string;
type: ShiftKey;
start_time: string;
end_time: string;
comment?: string;
is_remote?: boolean;
}
export interface CreateWeekShiftPayload {
shifts: CreateShiftPayload[];
}
export interface UpsertShiftsBody {
old_shift?: ShiftPayload;
new_shift?: ShiftPayload;
}
export interface DayShift {
start_time: string;
end_time: string;
type: string;
is_remote: boolean;
comment?: string | null;
}
export interface UpsertShiftsResponse {
action: UpsertAction;
day: DayShift[];
}

View File

@ -1,27 +0,0 @@
export const SHIFT_KEY = [
'REGULAR',
'EVENING',
'EMERGENCY',
'HOLIDAY',
'VACATION',
'SICK'
] as const;
export type ShiftKey = typeof SHIFT_KEY[number];
export type ShiftSelectOption = { value: ShiftKey; label: string };
export type ShiftPayload = {
start_time: string;
end_time: string;
type: ShiftKey;
is_remote: boolean;
comment?: string;
}
export type ShiftLegendItem = {
type: 'REGULAR'|'EVENING'|'EMERGENCY'|'OVERTIME'|'VACATION'|'HOLIDAY'|'SICK';
color: string;
label_key: string;
text_color?: string;
};

View File

@ -1,39 +0,0 @@
import type { WeekDay } from "./timesheet.types";
import type {
TimesheetDetailsDailyExpenses,
TimesheetDetailsDailySchedule,
TimesheetDetailsWeek
} from "./timesheet.interfaces";
const makeWeek = <T>(factory: ()=> T): WeekDay<T> => ({
sun: factory(),
mon: factory(),
tue: factory(),
wed: factory(),
thu: factory(),
fri: factory(),
sat: factory(),
});
const emptyDailySchedule = (): TimesheetDetailsDailySchedule => ({
shifts: [],
regular_hours: 0,
evening_hours: 0,
emergency_hours: 0,
overtime_hours: 0,
total_hours: 0,
comment: "",
short_date: "",
break_duration: 0,
});
const emptyDailyExpenses = (): TimesheetDetailsDailyExpenses => ({
cash: [],
km: [],
});
export const defaultTimesheetDetailsWeek = (): TimesheetDetailsWeek => ({
is_approved: false,
shifts: makeWeek(emptyDailySchedule),
expenses: makeWeek(emptyDailyExpenses),
});

View File

@ -1,54 +0,0 @@
import type { Shift } from "./shift.interfaces";
import type {
TimesheetExpenseEntry,
TimesheetShiftEntry,
WeekDay
} from "./timesheet.types";
export interface Timesheet {
is_approved: boolean;
start_day: string;
end_day: string;
label: string;
shifts: TimesheetShiftEntry[];
expenses: TimesheetExpenseEntry[];
}
export interface TimesheetDetailsWeek {
is_approved: boolean;
shifts: WeekDay<TimesheetDetailsDailySchedule>
expenses: WeekDay<TimesheetDetailsDailyExpenses>;
}
export interface TimesheetDetailsDailySchedule {
shifts: Shift[];
regular_hours: number;
evening_hours: number;
emergency_hours: number;
overtime_hours: number;
total_hours: number;
comment: string;
short_date: string;
break_duration?: number;
}
export interface DailyExpense {
is_approved: boolean;
comment: string;
amount: number;
supervisor_comment: string;
}
export interface TimesheetDetailsDailyExpenses {
cash: DailyExpense[];
km: DailyExpense[];
[otherType: string]: DailyExpense[];
}
export interface TimesheetPayPeriodDetailsOverview {
week1: TimesheetDetailsWeek;
week2: TimesheetDetailsWeek;
}

View File

@ -1,29 +0,0 @@
export type TimesheetShiftEntry = {
bank_type: string;
date: string;
start_time: string;
end_time: string;
comment: string;
is_approved: boolean;
is_remote: boolean;
};
export type TimesheetExpenseEntry = {
bank_type: string;
date: string;
amount: number;
km: number;
comment: string;
is_approved: boolean;
supervisor_comment: string;
};
export type WeekDay<T> = {
sun: T;
mon: T;
tue: T;
wed: T;
thu: T;
fri: T;
sat: T;
};

View File

@ -1,9 +1,8 @@
import type { TimesheetExpense } from "../types/expense.interfaces"; import type { Expense, ExpenseTotals } from "src/modules/timesheets/models/expense.models";
import type { ExpenseSavePayload, ExpenseTotals, ExpenseType } from "../types/expense.types";
/* eslint-disable */
//------------------ normalization / icons ------------------ //------------------ normalization / icons ------------------
export const normExpenseType = (type: unknown): string => export const normExpenseType = (type: unknown): string =>
String(type ?? '').trim().toUpperCase(); typeof type === 'string' ? type.trim().toUpperCase() : '';
const icon_map: Record<string,string> = { const icon_map: Record<string,string> = {
MILEAGE: 'time_to_leave', MILEAGE: 'time_to_leave',
@ -21,16 +20,8 @@ export const expenseTypeIcon = (type: unknown): string => {
); );
}; };
//------------------ q-select options ------------------
export const buildExpenseTypeOptions = ( types: readonly ExpenseType[], t: (key:string) => string):
{ label: string; value: ExpenseType } [] =>
types.map((val)=> ({
label: t(`timesheet.expense.types.${val}`),
value: val,
}));
//------------------ totals ------------------ //------------------ totals ------------------
export const computeExpenseTotals = (items: readonly TimesheetExpense[]): ExpenseTotals => export const computeExpenseTotals = (items: readonly Expense[]): ExpenseTotals =>
items.reduce<ExpenseTotals>( items.reduce<ExpenseTotals>(
(acc, e) => ({ (acc, e) => ({
amount: acc.amount + (Number(e.amount) || 0), amount: acc.amount + (Number(e.amount) || 0),
@ -40,7 +31,7 @@ export const computeExpenseTotals = (items: readonly TimesheetExpense[]): Expens
); );
//------------------ Quasar :rules=[] ------------------ //------------------ Quasar :rules=[] ------------------
export const makeExpenseRules = (t: (key: string) => string, max_comment_char: number) => { export const makeExpenseRules = (t: (_key: string) => string) => {
const isPresent = (val: unknown) => val !== undefined && val !== null && val !== ''; const isPresent = (val: unknown) => val !== undefined && val !== null && val !== '';
const typeRequired = (val: unknown) => (!!val) || t('timesheet.expense.errors.type_required'); const typeRequired = (val: unknown) => (!!val) || t('timesheet.expense.errors.type_required');
@ -49,28 +40,12 @@ export const makeExpenseRules = (t: (key: string) => string, max_comment_char: n
const mileageRequired = (val: unknown) => (isPresent(val)) || t('timesheet.expense.errors.mileage_required_for_type'); const mileageRequired = (val: unknown) => (isPresent(val)) || t('timesheet.expense.errors.mileage_required_for_type');
const commentRequired = (val: unknown) => (String(val ?? '').trim().length > 0) || t('timesheet.expense.errors.comment_required'); const commentRequired = (val: unknown) => (typeof val === 'string' ? val.trim().length > 0 : false ) || t('timesheet.expense.errors.comment_required');
const commentTooLong = (val: unknown) => (String(val ?? '').trim().length <= max_comment_char) || t('timesheet.expense.errors.comment_too_long');
return { return {
typeRequired, typeRequired,
amountRequired, amountRequired,
mileageRequired, mileageRequired,
commentRequired, commentRequired,
commentTooLong,
}; };
}; };
//------------------ saving payload ------------------
export const buildExpenseSavePayload = (args: {
pay_period_no: number;
pay_year: number;
email: string;
expenses: TimesheetExpense[];
}): ExpenseSavePayload => ({
pay_period_no: args.pay_period_no,
pay_year: args.pay_year,
email: args.email,
expenses: args.expenses,
});

View File

@ -1,117 +1,130 @@
import { COMMENT_MAX_LENGTH, DATE_FORMAT_PATTERN } from "../constants/expense.constants"; // import { COMMENT_MAX_LENGTH, DATE_FORMAT_PATTERN } from "src/modules/timesheets/constants/expense.constants";
import { ExpensesValidationError } from "../types/expense-validation.interface"; // import { ExpensesValidationError } from "src/modules/timesheets/models/expense.validation";
import type { TimesheetExpense } from "../types/expense.interfaces"; // import { type Expense, type ExpenseType, TYPES_WITH_AMOUNT_ONLY, TYPES_WITH_MILEAGE_ONLY } from "src/modules/timesheets/models/expense.models";
import {
type ExpenseType,
TYPES_WITH_AMOUNT_ONLY,
TYPES_WITH_MILEAGE_ONLY
} from "../types/expense.types";
//normalization helpers // //normalization helpers
export const toNumOrUndefined = (value: unknown): number | undefined => { // export const toNumOrUndefined = (value: unknown): number | undefined => {
if(value === undefined || value === null || (typeof value === 'string' && value.trim() === '')) return undefined; // if(value === undefined || value === null || (typeof value === 'string' && value.trim() === '')) return undefined;
const num = Number(value); // const num = Number(value);
return Number.isFinite(num) ? num : undefined;
};
export const normalizeComment = (input?: string): string | undefined => {
if(typeof input === 'undefined' || input === null) return undefined;
const trimmed = String(input).trim();
return trimmed.length ? trimmed : undefined;
};
export const normalizeType = (input: string): string => (input ?? '').trim().toUpperCase();
export const normalizeExpense = (expense: TimesheetExpense): TimesheetExpense => {
const comment = normalizeComment(expense.comment);
const amount = toNumOrUndefined(expense.amount);
const mileage = toNumOrUndefined(expense.mileage);
return {
date: (expense.date ?? '').trim(),
type: normalizeType(expense.type),
...(amount !== undefined ? { amount } : {}),
...(mileage !== undefined ? { mileage } : {}),
...(comment !== undefined ? { comment } : {}),
...(typeof expense.supervisor_comment === 'string' && expense.supervisor_comment.trim().length
? { supervisor_comment: expense.supervisor_comment.trim() }
: {}),
...(typeof expense.is_approved === 'boolean' ? { is_approved: expense.is_approved }: {} ),
};
};
//UI validation error messages
export const validateExpenseUI = (raw: TimesheetExpense, label: string = 'expense'): void => {
const expense = normalizeExpense(raw);
//Date input validation
if(!DATE_FORMAT_PATTERN.test(expense.date)) {
throw new ExpensesValidationError({
status_code: 400,
message: 'timesheet.expense.errors.date_required_or_invalid',
context: { [label]: expense },
});
}
//comment input validation
if(!expense.comment) {
throw new ExpensesValidationError({
status_code: 400,
message: 'timesheet.expense.errors.comment_required',
context: { [label]: expense },
})
}
if((expense.comment.length ?? 0) > COMMENT_MAX_LENGTH) {
throw new ExpensesValidationError({
status_code: 400,
message: 'timesheet.expense.errors.comment_too_long',
context: { [label]: { ...expense, comment_length: expense.comment?.length } },
});
}
//amount input validation
if(expense.amount !== undefined && expense.amount <= 0) {
throw new ExpensesValidationError({
status_code: 400,
message: 'timesheet.expense.errors.amount_must_be_positive',
context: { [label]: expense },
});
}
//mileage input validation // return Number.isFinite(num) ? num : undefined;
if(expense.mileage !== undefined && expense.mileage <= 0) { // };
throw new ExpensesValidationError({
status_code: 400,
message: 'timesheet.expense.errors.mileage_must_be_positive',
context: { [label]: expense },
});
}
//cross origin amount/mileage validation // export const normalizeComment = (input?: string): string | undefined => {
const has_amount = typeof expense.amount === 'number' && expense.amount > 0; // if(typeof input === 'undefined' || input === null) return undefined;
const has_mileage = typeof expense.mileage === 'number' && expense.mileage > 0; // const trimmed = String(input).trim();
// return trimmed.length ? trimmed : undefined;
// };
if(has_amount === has_mileage) { // export const normalizeType = (input: string): string => (input ?? '').trim().toUpperCase();
throw new ExpensesValidationError({
status_code: 400,
message: 'timesheet.expense.errors.amount_xor_mileage',
context: { [label]: expense },
});
}
//type constraint validation // export const normalizeExpense = (expense: Expense): Expense => {
const type = expense.type as ExpenseType; // const comment = normalizeComment(expense.comment);
if(TYPES_WITH_MILEAGE_ONLY.includes(type) && !has_mileage) { // const amount = toNumOrUndefined(expense.amount);
throw new ExpensesValidationError({ // const mileage = toNumOrUndefined(expense.mileage);
status_code: 400,
message: 'timesheet.expense.errors.mileage_required_for_type', // return {
context: { [label]: expense }, // date: (expense.date ?? '').trim(),
}); // type: normalizeType(expense.type),
} // ...(amount !== undefined ? { amount } : {}),
if(TYPES_WITH_AMOUNT_ONLY.includes(type) && !has_amount) { // ...(mileage !== undefined ? { mileage } : {}),
throw new ExpensesValidationError({ // ...(comment !== undefined ? { comment } : {}),
status_code: 400, // ...(typeof expense.supervisor_comment === 'string' && expense.supervisor_comment.trim().length
message: 'timesheet.expense.errors.amount_required_for_type', // ? { supervisor_comment: expense.supervisor_comment.trim() }
context: { [label]: expense }, // : {}),
}); // ...(typeof expense.is_approved === 'boolean' ? { is_approved: expense.is_approved }: {} ),
} // };
}; // };
// //UI validation error messages
// export const validateExpenseUI = (raw: Expense, label: string = 'expense'): void => {
// const expense = normalizeExpense(raw);
// //Date input validation
// if(!DATE_FORMAT_PATTERN.test(expense.date)) {
// throw new ExpensesValidationError({
// status_code: 400,
// message: 'timesheet.expense.errors.date_required_or_invalid',
// context: { [label]: expense },
// });
// }
// //comment input validation
// if(!expense.comment) {
// throw new ExpensesValidationError({
// status_code: 400,
// message: 'timesheet.expense.errors.comment_required',
// context: { [label]: expense },
// })
// }
// if((expense.comment.length ?? 0) > COMMENT_MAX_LENGTH) {
// throw new ExpensesValidationError({
// status_code: 400,
// message: 'timesheet.expense.errors.comment_too_long',
// context: { [label]: { ...expense, comment_length: expense.comment?.length } },
// });
// }
// //amount input validation
// if(expense.amount !== undefined && expense.amount <= 0) {
// throw new ExpensesValidationError({
// status_code: 400,
// message: 'timesheet.expense.errors.amount_must_be_positive',
// context: { [label]: expense },
// });
// }
// //mileage input validation
// if(expense.mileage !== undefined && expense.mileage <= 0) {
// throw new ExpensesValidationError({
// status_code: 400,
// message: 'timesheet.expense.errors.mileage_must_be_positive',
// context: { [label]: expense },
// });
// }
// //cross origin amount/mileage validation
// const has_amount = typeof expense.amount === 'number' && expense.amount > 0;
// const has_mileage = typeof expense.mileage === 'number' && expense.mileage > 0;
// if(has_amount === has_mileage) {
// throw new ExpensesValidationError({
// status_code: 400,
// message: 'timesheet.expense.errors.amount_xor_mileage',
// context: { [label]: expense },
// });
// }
// //type constraint validation
// const type = expense.type as ExpenseType;
// if( TYPES_WITH_MILEAGE_ONLY.includes(type) && !has_mileage ) {
// throw new ExpensesValidationError({
// status_code: 400,
// message: 'timesheet.expense.errors.mileage_required_for_type',
// context: { [label]: expense },
// });
// }
// if(TYPES_WITH_AMOUNT_ONLY.includes(type) && !has_amount) {
// throw new ExpensesValidationError({
// status_code: 400,
// message: 'timesheet.expense.errors.amount_required_for_type',
// context: { [label]: expense },
// });
// }
// };
// <<<<<<< HEAD
// //totals per pay-period
// export const compute_expense_totals = (items: Expense[]) => items.reduce(
// (acc, raw) => {
// const expense = normalizeExpense(raw);
// if(typeof expense.amount === 'number' && expense.amount > 0) acc.amount += expense.amount;
// if(typeof expense.mileage === 'number' && expense.mileage > 0) acc.mileage += expense.mileage;
// return acc;
// },
// { amount: 0, mileage: 0 }
// );
// =======
// >>>>>>> 1bdbe021facc85fb50cff6c60053278695df6bdc

View File

@ -1,19 +1,18 @@
import type { ShiftKey, ShiftPayload, ShiftSelectOption } from "../types/shift.types"; // import type { ShiftKey, ShiftPayload, ShiftSelectOption } from "../types/shift.types";
/* eslint-disable */
export const toShiftPayload = (shift: any): ShiftPayload => ({ // export const toShiftPayload = (shift: any): ShiftPayload => ({
start_time: String(shift.start_time), // start_time: String(shift.start_time),
end_time: String(shift.end_time), // end_time: String(shift.end_time),
type: String(shift.type).toUpperCase() as ShiftKey, // type: String(shift.type).toUpperCase() as ShiftKey,
is_remote: !!shift.is_remote, // is_remote: !!shift.is_remote,
...(shift.comment ? { comment: String(shift.comment) } : {}), // ...(shift.comment ? { comment: String(shift.comment) } : {}),
}); // });
export const buildShiftOptions = ( // export const buildShiftOptions = (
keys: readonly string[], // keys: readonly string[],
t:(k: string) => string // t:(k: string) => string
): ShiftSelectOption[] => // ): ShiftSelectOption[] =>
keys.map((key) => ({ // keys.map((key) => ({
value: key as any, // value: key as any,
label: t(`timesheet.shift.types.${key}`), // label: t(`timesheet.shift.types.${key}`),
})); // }));

View File

@ -1,4 +1,4 @@
import type { PayPeriodLabel } from "../types/ui.types"; import type { PayPeriodLabel } from "src/modules/timesheets/models/ui.models";
export const formatPayPeriodLabel = ( export const formatPayPeriodLabel = (
raw_label: string | undefined, raw_label: string | undefined,

View File

@ -1,23 +1,20 @@
<script setup lang="ts">
/* eslint-disable */
</script>
<template> <template>
<q-layout view="hHh lpR fFf"> <q-layout view="hHh lpR fFf">
<q-page-container> <q-page-container>
<q-page padding class="column justify-center items-center bg-secondary"> <q-page padding class="column justify-center items-center bg-secondary">
<q-card class="col-shrink rounded-20"> <q-card class="col-shrink rounded-20">
<q-img src="src/assets/line-truck-1.jpg" height="20vh"> <q-img src="src/assets/line-truck-1.jpg" height="20vh">
<div class="absolute-bottom text-h4 text-center text-weight-bolder justify-center items-center row"> <div
<div class="q-pr-md text-primary text-h3 text-weight-bolder">404</div> class="absolute-bottom text-h4 text-center text-weight-bolder justify-center items-center row">
PAGE NOT FOUND <div class="q-pr-md text-primary text-h3 text-weight-bolder">404</div>
</div> PAGE NOT FOUND
</q-img> </div>
<q-card-section class="text-center text-h5 text-primary"> </q-img>
{{$t('notFoundPage.pageText')}} <q-card-section class="text-center text-h5 text-primary">
</q-card-section> {{ $t('notFoundPage.pageText') }}
</q-card> </q-card-section>
</q-page> </q-card>
</q-page-container> </q-page>
</q-layout> </q-page-container>
</q-layout>
</template> </template>

Some files were not shown because too many files have changed in this diff Show More