Merge pull request 'dev/nicolas/approvals-DRYing' (#21) from dev/nicolas/approvals-DRYing into main
Reviewed-on: Targo/targo_frontend#21
This commit is contained in:
commit
7af13db81e
|
|
@ -106,7 +106,7 @@ export default defineConfig((ctx) => {
|
|||
color: 'primary',
|
||||
avatar: 'https://cdn.quasar.dev/img/boy-avatar.png',
|
||||
},
|
||||
dark: "auto",
|
||||
dark: false,
|
||||
},
|
||||
|
||||
// iconSet: 'material-icons', // Quasar icon set
|
||||
|
|
@ -127,15 +127,7 @@ export default defineConfig((ctx) => {
|
|||
|
||||
// animations: 'all', // --- includes all animations
|
||||
// https://v2.quasar.dev/options/animations
|
||||
animations: [
|
||||
'fadeIn',
|
||||
'fadeOut',
|
||||
'fadeInUp',
|
||||
'zoomIn',
|
||||
'zoomOut',
|
||||
'flipInX',
|
||||
'flipOutX',
|
||||
],
|
||||
animations: 'all',
|
||||
|
||||
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#sourcefiles
|
||||
// sourceFiles: {
|
||||
|
|
|
|||
|
|
@ -31,5 +31,5 @@ body.body--dark {
|
|||
|
||||
.body--light {
|
||||
--q-dark: #FFF;
|
||||
color: $grey-8;
|
||||
color: $blue-grey-8;
|
||||
}
|
||||
|
|
@ -74,6 +74,7 @@ export default {
|
|||
},
|
||||
label: {
|
||||
search: "search",
|
||||
filter: "filters",
|
||||
loading: "loading...",
|
||||
language: "Language",
|
||||
add: "ajouter",
|
||||
|
|
@ -82,6 +83,7 @@ export default {
|
|||
cancel: "cancel",
|
||||
update: "update",
|
||||
modify: "modify",
|
||||
close: "close",
|
||||
},
|
||||
misc: {
|
||||
or: "or",
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ export default {
|
|||
},
|
||||
label: {
|
||||
search: 'recherche',
|
||||
filter: "filtres",
|
||||
loading: 'chargement en cours...',
|
||||
language: 'langue',
|
||||
add: "ajouter",
|
||||
|
|
@ -82,6 +83,7 @@ export default {
|
|||
cancel: "annuler",
|
||||
update: "mettre à jour",
|
||||
modify: "modifier",
|
||||
close: "fermer",
|
||||
},
|
||||
misc: {
|
||||
or: "ou",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script lang="ts" setup>
|
||||
import { useUiStore } from 'src/stores/ui-store';
|
||||
import HeaderBarAvatar from './header-bar-avatar.vue';
|
||||
import HeaderBarNotification from './main-layout-header-bar-notification.vue';
|
||||
|
||||
const uiStore = useUiStore();
|
||||
</script>
|
||||
|
|
@ -14,7 +14,7 @@
|
|||
</q-btn>
|
||||
</q-toolbar-title>
|
||||
<q-item class="q-pa-none">
|
||||
<HeaderBarAvatar />
|
||||
<HeaderBarNotification />
|
||||
</q-item>
|
||||
</q-toolbar>
|
||||
</q-header>
|
||||
|
|
@ -26,8 +26,16 @@
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<q-drawer overlay elevated side="left" :mini="miniState" @mouseenter="miniState = false"
|
||||
@mouseleave="miniState = true" v-model="uiStore.isRightDrawerOpen">
|
||||
<q-drawer
|
||||
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-list>
|
||||
<!-- Home -->
|
||||
|
|
@ -1,14 +1,14 @@
|
|||
<script lang="ts" setup>
|
||||
import { RouterView } from 'vue-router';
|
||||
import HeaderBar from 'src/modules/shared/components/navigation/header-bar.vue';
|
||||
import FooterBar from 'src/modules/shared/components/navigation/footer-bar.vue';
|
||||
import RightDrawer from 'src/modules/shared/components/navigation/right-drawer.vue';
|
||||
import HeaderBar from 'src/layouts/components/main-layout-header-bar.vue';
|
||||
import FooterBar from 'src/layouts/components/main-layout-footer-bar.vue';
|
||||
import LeftDrawer from 'src/layouts/components/main-layout-left-drawer.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-layout view="hHh lpR fFf">
|
||||
<HeaderBar />
|
||||
<RightDrawer />
|
||||
<LeftDrawer />
|
||||
<q-page-container>
|
||||
<router-view class="q-pa-sm bg-secondary" />
|
||||
</q-page-container>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
token: string;
|
||||
30
src/modules/shared/components/page-header-template.vue
Normal file
30
src/modules/shared/components/page-header-template.vue
Normal 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>
|
||||
|
|
@ -1,71 +1,76 @@
|
|||
<script setup lang="ts">
|
||||
/* eslint-disable */
|
||||
import { ref } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
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 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]
|
||||
'date-selected': [ value: string ]
|
||||
'pressed-previous-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;
|
||||
is_showing_calendar_picker.value = false;
|
||||
emit('date-selected', value, reason, details);
|
||||
emit('date-selected', value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="row q-mb-lg q-mt-lg" >
|
||||
<div class="row" >
|
||||
<!-- navigation to previous week -->
|
||||
<q-btn
|
||||
push rounded
|
||||
icon="keyboard_arrow_left"
|
||||
color="primary"
|
||||
@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"
|
||||
>
|
||||
<q-tooltip
|
||||
anchor="top middle"
|
||||
self="center middle"
|
||||
class="bg-primary text-uppercase text-weight-bold"
|
||||
> {{ $t( 'timesheet.nav_button.previous_week' )}}
|
||||
>
|
||||
{{ $t( 'timesheet.nav_button.previous_week' )}}
|
||||
</q-tooltip>
|
||||
</q-btn>
|
||||
|
||||
<!-- navigation through calendar date picker -->
|
||||
<q-btn
|
||||
push rounded
|
||||
icon="calendar_month"
|
||||
color="primary"
|
||||
@click="is_showing_calendar_picker = true"
|
||||
:disable="props.isDisabled"
|
||||
class="q-px-lg"
|
||||
:disable="timesheet_store.is_loading"
|
||||
class="q-px-xl"
|
||||
>
|
||||
<q-tooltip
|
||||
anchor="top middle"
|
||||
self="center middle"
|
||||
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-btn>
|
||||
|
||||
<!-- navigation to next week -->
|
||||
<q-btn
|
||||
push rounded
|
||||
icon="keyboard_arrow_right"
|
||||
color="primary"
|
||||
@click="emit('pressed-next-button')"
|
||||
:disable="props.isDisabled"
|
||||
:disable="timesheet_store.is_loading"
|
||||
class="q-ml-sm q-px-sm"
|
||||
>
|
||||
<q-tooltip
|
||||
35
src/modules/shared/components/q-table-filters.vue
Normal file
35
src/modules/shared/components/q-table-filters.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
};
|
||||
};
|
||||
17
src/modules/shared/models/pay-period.models.ts
Normal file
17
src/modules/shared/models/pay-period.models.ts
Normal 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: ''
|
||||
};
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
export interface PayPeriod {
|
||||
pay_period_no: number;
|
||||
period_start: string;
|
||||
period_end: string;
|
||||
payday: string;
|
||||
pay_year: number;
|
||||
label: string;
|
||||
}
|
||||
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -4,8 +4,8 @@
|
|||
import { Bar } from 'vue-chartjs';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
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 type { PayPeriodEmployeeDetails } from 'src/modules/timesheet-approval/types/timesheet-approval-pay-period-employee-details-interface';
|
||||
import { Chart as ChartJS, Title, Tooltip, Legend, BarElement, CategoryScale, LinearScale, type ChartData, type ChartDataset } from 'chart.js';
|
||||
import { useTimesheetStore } from 'src/stores/timesheet-store';
|
||||
|
||||
const { t } = useI18n();
|
||||
const $q = useQuasar();
|
||||
|
|
@ -15,19 +15,14 @@
|
|||
ChartJS.defaults.maintainAspectRatio = false;
|
||||
ChartJS.defaults.color = $q.dark.isActive ? '#F5F5F5' : '#616161';
|
||||
|
||||
const props = defineProps<{
|
||||
rawData: PayPeriodEmployeeDetails | undefined;
|
||||
options?: ChartOptions<"bar"> | undefined;
|
||||
plugins?: Plugin<"bar">[] | undefined;
|
||||
}>();
|
||||
const { pay_period_details } = useTimesheetStore();
|
||||
|
||||
const hours_worked_labels = ref<string[]>([]);
|
||||
const hours_worked_dataset = ref<ChartDataset<'bar'>[]>([]);
|
||||
|
||||
const getHoursWorkedData = (): ChartData<'bar'> => {
|
||||
if (props.rawData) {
|
||||
const all_weeks = [props.rawData.week1, props.rawData.week2];
|
||||
const all_days = all_weeks.flatMap( week => Object.values(week.shifts));
|
||||
|
||||
const all_days = pay_period_details.weeks.flatMap( week => Object.values(week.shifts));
|
||||
const datasetConfig = [
|
||||
{
|
||||
key: 'regular_hours',
|
||||
|
|
@ -58,7 +53,7 @@
|
|||
}));
|
||||
|
||||
hours_worked_labels.value = all_days.map(day => day.short_date);
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
labels: hours_worked_labels.value,
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
125
src/modules/timesheet-approval/components/overview-list-item.vue
Normal file
125
src/modules/timesheet-approval/components/overview-list-item.vue
Normal 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>
|
||||
91
src/modules/timesheet-approval/components/overview-list.vue
Normal file
91
src/modules/timesheet-approval/components/overview-list.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -1,92 +1,40 @@
|
|||
import { useTimesheetStore } from "src/stores/timesheet-store";
|
||||
import { useAuthStore } from "src/stores/auth-store";
|
||||
import type { PayPeriodReportFilters } from "../types/timesheet-approval-pay-period-report-interface";
|
||||
import { default_pay_period_overview_employee, type PayPeriodOverviewEmployee } from "../types/timesheet-approval-pay-period-overview-employee-interface";
|
||||
import { date } from "quasar";
|
||||
import type { TimesheetApprovalCSVReportFilters } from "src/modules/timesheet-approval/models/timesheet-approval-csv-report.models";
|
||||
|
||||
export const useTimesheetApprovalApi = () => {
|
||||
const timesheet_store = useTimesheetStore();
|
||||
const auth_store = useAuthStore();
|
||||
|
||||
const getPayPeriodOverviewByDate = async (date_string: string) => {
|
||||
const success = await timesheet_store.getPayPeriodByDate(date_string);
|
||||
const getPayPeriodOverviewsByDate = async (date_string: string): Promise<void> => {
|
||||
const success = await timesheet_store.getPayPeriodByDateOrYearAndNumber(date_string);
|
||||
|
||||
if (success) {
|
||||
const current_pay_period = timesheet_store.current_pay_period;
|
||||
await timesheet_store.getTimesheetApprovalPayPeriodEmployeeOverviews(current_pay_period.pay_year, current_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);
|
||||
await timesheet_store.getPayPeriodOverviewsBySupervisorEmail(
|
||||
timesheet_store.pay_period.pay_year,
|
||||
timesheet_store.pay_period.pay_period_no,
|
||||
auth_store.user.email
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const getTimesheetsByPayPeriodAndEmail = async (employee_email: string) => {
|
||||
await timesheet_store.getTimesheetsByPayPeriodAndEmail(employee_email);
|
||||
};
|
||||
|
||||
const getTimesheetApprovalCSVReport = async ( report_filter_company: boolean[], report_filter_type: boolean[] ) => {
|
||||
const getTimesheetApprovalCSVReport = async ( report_filter_company: boolean[], report_filter_type: boolean[], year?: number, period_number?: number ) => {
|
||||
const [ targo, solucom ] = report_filter_company;
|
||||
const [ shifts, expenses, holiday, vacation ] = report_filter_type;
|
||||
const options = {
|
||||
company: { targo, solucom },
|
||||
types: { shifts, expenses, holiday, vacation }
|
||||
} as PayPeriodReportFilters;
|
||||
types: { shifts, expenses, holiday, vacation },
|
||||
companies: { targo, solucom },
|
||||
} as TimesheetApprovalCSVReportFilters;
|
||||
|
||||
await timesheet_store.getTimesheetApprovalCSVReport(options);
|
||||
};
|
||||
|
||||
const getCurrentPayPerdioOverview = async (): Promise<void> => {
|
||||
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.getPayPeriodReportByYearAndPeriodNumber(
|
||||
year ?? timesheet_store.pay_period.pay_year,
|
||||
period_number ?? timesheet_store.pay_period.pay_period_no,
|
||||
options
|
||||
);
|
||||
|
||||
await timesheet_store.getTimesheetsByPayPeriodAndEmail(auth_store.user.email);
|
||||
|
||||
};
|
||||
|
||||
return {
|
||||
getPayPeriodOverviewByDate,
|
||||
getNextPayPeriodOverview,
|
||||
getPayPeriodOverviewByEmployeeEmail,
|
||||
getTimesheetsByPayPeriodAndEmail,
|
||||
getPayPeriodOverviewsByDate,
|
||||
getTimesheetApprovalCSVReport,
|
||||
getCurrentPayPerdioOverview
|
||||
}
|
||||
};
|
||||
|
|
@ -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',
|
||||
},
|
||||
]
|
||||
|
|
@ -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,
|
||||
}
|
||||
];
|
||||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
|
@ -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"
|
||||
// }
|
||||
// ]
|
||||
|
|
@ -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(),
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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[];
|
||||
};
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
// export interface PayPeriodReport {
|
||||
|
||||
// }
|
||||
|
||||
export interface PayPeriodReportFilters {
|
||||
company: {
|
||||
targo: boolean;
|
||||
solucom: boolean;
|
||||
};
|
||||
types: {
|
||||
shifts: boolean;
|
||||
expenses: boolean;
|
||||
holiday: boolean;
|
||||
vacation: boolean;
|
||||
};
|
||||
}
|
||||
|
|
@ -1,64 +1,54 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import type { QForm } from 'quasar';
|
||||
import type { TimesheetExpense } from '../../types/expense.interfaces';
|
||||
import type { ExpenseType } from '../../types/expense.types';
|
||||
/* eslint-disable */
|
||||
<script
|
||||
setup
|
||||
lang="ts"
|
||||
>
|
||||
import { inject, ref } from 'vue';
|
||||
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 draft = defineModel<Partial<TimesheetExpense>>('draft');
|
||||
const files = defineModel<File[] | null>('files');
|
||||
const datePickerOpen = defineModel<boolean | null>('datePickerOpen', {default: false });
|
||||
const { t } = useI18n();
|
||||
|
||||
//------------------ Props ------------------
|
||||
const {setType} = defineProps<{
|
||||
type_options: { label: string; value: ExpenseType }[];
|
||||
show_amount: boolean;
|
||||
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;
|
||||
}>();
|
||||
const expenses_store = useExpensesStore();
|
||||
const expenses_api = useExpensesApi();
|
||||
const files = defineModel<File[] | null>('files');
|
||||
const is_navigator_open = ref(false);
|
||||
|
||||
//------------------ Emits ------------------
|
||||
defineEmits<{
|
||||
'submit': [void];
|
||||
}>();
|
||||
const COMMENT_MAX_LENGTH = 280;
|
||||
const employee_email = inject<string>('employeeEmail');
|
||||
const rules = makeExpenseRules(t);
|
||||
|
||||
//------------------ Exposes ------------------
|
||||
const inner_form = ref<QForm | null>(null);
|
||||
defineExpose({
|
||||
validate: async ( force = true ) => (await inner_form.value?.validate(force)) === true,
|
||||
});
|
||||
|
||||
//------------------ Handlers ------------------
|
||||
const onTypeChange = (val: ExpenseType) => {
|
||||
setType(val);
|
||||
};
|
||||
const cancelUpdateMode = () => {
|
||||
expenses_store.current_expense = default_expense;
|
||||
expenses_store.initial_expense = default_expense;
|
||||
expenses_store.mode = 'create';
|
||||
}
|
||||
|
||||
const requestExpenseCreationOrUpdate = async () => {
|
||||
if (expenses_store.mode === 'create') await expenses_api.createExpenseByEmployeeEmail(employee_email ?? '', expenses_store.current_expense.date);
|
||||
else await expenses_api.updateExpenseByEmployeeEmail(employee_email ?? '', expenses_store.current_expense.date);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-form
|
||||
ref="inner_form"
|
||||
flat
|
||||
v-if="!is_readonly"
|
||||
@submit.prevent="$emit('submit')"
|
||||
v-if="!expenses_store.pay_period_expenses.is_approved"
|
||||
@submit.prevent="requestExpenseCreationOrUpdate"
|
||||
>
|
||||
<div class="text-subtitle2 q-py-sm">
|
||||
{{ $t('timesheet.expense.add_expense')}}
|
||||
{{ $t('timesheet.expense.add_expense') }}
|
||||
</div>
|
||||
<div class="row justify-between">
|
||||
<div
|
||||
class="row justify-between rounded-5"
|
||||
:class="expenses_store.mode === 'update' ? 'bg-accent' : ''"
|
||||
>
|
||||
|
||||
<!-- date selection input -->
|
||||
<q-input
|
||||
v-model="draft!.date"
|
||||
v-model="expenses_store.current_expense.date"
|
||||
dense
|
||||
filled
|
||||
readonly
|
||||
|
|
@ -73,12 +63,12 @@ const onTypeChange = (val: ExpenseType) => {
|
|||
dense
|
||||
icon="event"
|
||||
color="primary"
|
||||
@click="datePickerOpen = true"
|
||||
@click="is_navigator_open = true"
|
||||
/>
|
||||
<q-dialog v-model="datePickerOpen">
|
||||
<q-dialog v-model="is_navigator_open">
|
||||
<q-date
|
||||
v-model="draft!.date"
|
||||
@update:model-value="datePickerOpen = false"
|
||||
v-model="expenses_store.current_expense.date"
|
||||
@update:model-value="is_navigator_open = false"
|
||||
mask="YYYY-MM-DD"
|
||||
/>
|
||||
</q-dialog>
|
||||
|
|
@ -87,8 +77,8 @@ const onTypeChange = (val: ExpenseType) => {
|
|||
|
||||
<!-- expenses type selection -->
|
||||
<q-select
|
||||
v-model="draft!.type"
|
||||
:options="type_options"
|
||||
v-model="expenses_store.current_expense.type"
|
||||
:options="EXPENSE_TYPE"
|
||||
filled
|
||||
dense
|
||||
class="col q-px-xs"
|
||||
|
|
@ -96,15 +86,15 @@ const onTypeChange = (val: ExpenseType) => {
|
|||
emit-value
|
||||
map-options
|
||||
:label="$t('timesheet.expense.type')"
|
||||
:rules="[ rules.typeRequired ]"
|
||||
@update:model-value="val => setType(val as ExpenseType)"
|
||||
:rules="[rules.typeRequired]"
|
||||
:option-label="label => $t(label)"
|
||||
/>
|
||||
|
||||
<!-- amount input -->
|
||||
<template v-if="show_amount">
|
||||
<template v-if="TYPES_WITH_AMOUNT_ONLY.includes(expenses_store.current_expense.type)">
|
||||
<q-input
|
||||
key="amount"
|
||||
v-model.number="draft!.amount"
|
||||
v-model.number="expenses_store.current_expense.amount"
|
||||
filled
|
||||
input-class="text-right"
|
||||
dense
|
||||
|
|
@ -115,7 +105,7 @@ const onTypeChange = (val: ExpenseType) => {
|
|||
:label="$t('timesheet.expense.amount')"
|
||||
suffix="$"
|
||||
lazy-rules="ondemand"
|
||||
:rules="[ rules.amountRequired ]"
|
||||
:rules="[rules.amountRequired]"
|
||||
/>
|
||||
</template>
|
||||
|
||||
|
|
@ -123,7 +113,7 @@ const onTypeChange = (val: ExpenseType) => {
|
|||
<template v-else>
|
||||
<q-input
|
||||
key="mileage"
|
||||
v-model.number="draft!.mileage"
|
||||
v-model.number="expenses_store.current_expense.mileage"
|
||||
filled
|
||||
input-class="text-right"
|
||||
dense
|
||||
|
|
@ -134,13 +124,13 @@ const onTypeChange = (val: ExpenseType) => {
|
|||
:label="$t('timesheet.expense.mileage')"
|
||||
suffix="km"
|
||||
lazy-rules="ondemand"
|
||||
:rules="[ rules.mileageRequired ]"
|
||||
:rules="[rules.mileageRequired]"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- employee comment input -->
|
||||
<q-input
|
||||
v-model="draft!.comment"
|
||||
v-model="expenses_store.current_expense.comment"
|
||||
filled
|
||||
color="primary"
|
||||
type="text"
|
||||
|
|
@ -149,9 +139,9 @@ const onTypeChange = (val: ExpenseType) => {
|
|||
stack-label
|
||||
clearable
|
||||
:counter="true"
|
||||
:maxlength="comment_max_length"
|
||||
:maxlength="COMMENT_MAX_LENGTH"
|
||||
lazy-rules="ondemand"
|
||||
:rules="[ rules.commentRequired, rules.commentTooLong ]"
|
||||
:rules="[rules.commentRequired]"
|
||||
>
|
||||
<template #label>
|
||||
<span class="text-weight-bold ">
|
||||
|
|
@ -177,13 +167,21 @@ const onTypeChange = (val: ExpenseType) => {
|
|||
name="attach_file"
|
||||
size="sm"
|
||||
color="primary"
|
||||
|
||||
/>
|
||||
</template>
|
||||
</q-file>
|
||||
|
||||
<!-- add btn section -->
|
||||
<div>
|
||||
<q-btn
|
||||
v-if="expenses_store.mode === 'update'"
|
||||
flat
|
||||
dense
|
||||
size="sm"
|
||||
class="q-mt-sm q-ml-sm"
|
||||
@click="cancelUpdateMode"
|
||||
/>
|
||||
|
||||
<q-btn
|
||||
push
|
||||
dense
|
||||
|
|
@ -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>
|
||||
151
src/modules/timesheets/components/expense-crud-dialog-list.vue
Normal file
151
src/modules/timesheets/components/expense-crud-dialog-list.vue
Normal 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>
|
||||
|
|
@ -1,26 +1,29 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
/* eslint-disable */
|
||||
const props = defineProps<{
|
||||
commentString: string;
|
||||
}>();
|
||||
<script
|
||||
setup
|
||||
lang="ts"
|
||||
>
|
||||
import { ref } from 'vue';
|
||||
|
||||
const emit = defineEmits<{
|
||||
const { commentString } = defineProps<{
|
||||
commentString: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
clickClose: [];
|
||||
clickSave: [comment: string];
|
||||
}>();
|
||||
}>();
|
||||
|
||||
const text = ref(props.commentString);
|
||||
const text = ref(commentString);
|
||||
|
||||
const close = ()=> {
|
||||
const close = () => {
|
||||
emit('clickClose');
|
||||
text.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
const save = ()=> {
|
||||
emit('clickSave',text.value);
|
||||
const save = () => {
|
||||
emit('clickSave', text.value);
|
||||
close();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -28,14 +31,14 @@ const save = ()=> {
|
|||
<div class="row items-center justify-between q-pa-md">
|
||||
{{ $t('timesheet.fields.header_comment') }}
|
||||
</div>
|
||||
<q-separator/>
|
||||
<q-separator />
|
||||
<div class="q-pa-md">
|
||||
<q-input
|
||||
v-model="text"
|
||||
type="textarea"
|
||||
autogrow
|
||||
filled
|
||||
:label= "$t('timesheet.fields.textarea_comment')"
|
||||
:label="$t('timesheet.fields.textarea_comment')"
|
||||
:counter=true
|
||||
maxlength="512"
|
||||
color="primary"
|
||||
58
src/modules/timesheets/components/expense-crud-dialog.vue
Normal file
58
src/modules/timesheets/components/expense-crud-dialog.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
173
src/modules/timesheets/components/shift-crud-dialog.vue
Normal file
173
src/modules/timesheets/components/shift-crud-dialog.vue
Normal 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>
|
||||
72
src/modules/timesheets/components/shift-list-legend.vue
Normal file
72
src/modules/timesheets/components/shift-list-legend.vue
Normal 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>
|
||||
|
|
@ -1,69 +1,71 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import type { Shift } from '../../types/shift.interfaces';
|
||||
import { computed } from 'vue';
|
||||
import type { Shift } from 'src/modules/timesheets/models/shift.models';
|
||||
|
||||
/* eslint-disable */
|
||||
const props = defineProps<{
|
||||
|
||||
const { shift, dense = false } = defineProps<{
|
||||
shift: Shift;
|
||||
dense?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'save-comment' : [payload: { comment: string; shift: Shift }];
|
||||
'request-edit' : [payload: { shift: Shift }];
|
||||
'request-delete': [payload: { shift: Shift }];
|
||||
'save-comment': [comment: string, shift: Shift];
|
||||
'request-update': [shift: Shift];
|
||||
'request-delete': [shift: Shift];
|
||||
}>();
|
||||
|
||||
const has_comment = computed(()=> {
|
||||
const comment = (props.shift as any).description ?? (props.shift as any).comment ?? '';
|
||||
const has_comment = computed(() => {
|
||||
const comment = shift.comment ?? '';
|
||||
return typeof comment === 'string' && comment.trim().length > 0;
|
||||
})
|
||||
const comment_icon = computed(()=> (has_comment.value ? 'announcement' : 'chat_bubble_outline'));
|
||||
const comment_color = computed(()=> (has_comment.value ? 'primary' : 'grey-8'));
|
||||
|
||||
const comment_icon = computed(() => (has_comment.value ? 'announcement' : 'chat_bubble_outline'));
|
||||
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 => {
|
||||
switch(type) {
|
||||
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 'HOLIDAY': return 'purple-5';
|
||||
case 'SICK': return 'grey-8';
|
||||
default : return 'transparent';
|
||||
default: return 'transparent';
|
||||
}
|
||||
};
|
||||
|
||||
const get_text_color = (type: string): string => {
|
||||
switch(type) {
|
||||
switch (type) {
|
||||
case 'REGULAR': return 'grey-8';
|
||||
case '': return 'grey-5';
|
||||
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>
|
||||
|
||||
<template>
|
||||
<q-card-section
|
||||
horizontal
|
||||
class="q-pa-none text-uppercase text-center items-center rounded-10"
|
||||
:class="props.shift.type"
|
||||
:class="shift.type"
|
||||
style="line-height: 1;"
|
||||
@click.stop="on_click_edit(props.shift.type)"
|
||||
@click.stop="onClickUpdate(shift.type)"
|
||||
>
|
||||
<!-- 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-' + get_shift_color(props.shift.type) + ' text-' + get_text_color(props.shift.type)"
|
||||
style="font-size: 1.5em; line-height: 80% !important;"
|
||||
:class="'bg-' + get_shift_color(shift.type) + ' text-' + get_text_color(shift.type)"
|
||||
:style="'font-size: ' + hour_font_size + '; line-height: 80% !important;'"
|
||||
>
|
||||
{{ props.shift.start_time }}
|
||||
{{ shift.start_time }}
|
||||
</q-item-label>
|
||||
</q-card-section>
|
||||
|
||||
|
|
@ -79,7 +81,7 @@ const on_click_delete = () => emit('request-delete', { shift: props.shift });
|
|||
:key="index"
|
||||
>
|
||||
<q-icon
|
||||
v-if="props.shift.type"
|
||||
v-if="shift.type"
|
||||
name="double_arrow"
|
||||
:color="icon_data.color"
|
||||
size="24px"
|
||||
|
|
@ -92,20 +94,18 @@ const on_click_delete = () => emit('request-delete', { shift: props.shift });
|
|||
<q-card-section class="q-pa-none col">
|
||||
<q-item-label
|
||||
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;"
|
||||
>
|
||||
{{ props.shift.end_time }}
|
||||
{{ shift.end_time }}
|
||||
</q-item-label>
|
||||
</q-card-section>
|
||||
|
||||
<!-- comment and expenses buttons -->
|
||||
<q-card-section
|
||||
class="col q-pa-none text-right"
|
||||
>
|
||||
<q-card-section class="col q-pa-none text-right">
|
||||
<!-- comment btn -->
|
||||
<q-icon
|
||||
v-if="props.shift.type"
|
||||
v-if="shift.type"
|
||||
:name="comment_icon"
|
||||
:color="comment_color"
|
||||
class="q-pa-none q-mx-xs"
|
||||
|
|
@ -113,7 +113,7 @@ const on_click_delete = () => emit('request-delete', { shift: props.shift });
|
|||
/>
|
||||
<!-- expenses btn -->
|
||||
<q-btn
|
||||
v-if="props.shift.type"
|
||||
v-if="shift.type"
|
||||
flat
|
||||
dense
|
||||
color='grey-8'
|
||||
|
|
@ -122,14 +122,14 @@ const on_click_delete = () => emit('request-delete', { shift: props.shift });
|
|||
/>
|
||||
<!-- delete btn -->
|
||||
<q-btn
|
||||
v-if="props.shift.type"
|
||||
v-if="shift.type"
|
||||
push
|
||||
dense
|
||||
size="sm"
|
||||
color="red-6"
|
||||
icon="close"
|
||||
class="q-ml-xs"
|
||||
@click.stop="on_click_delete"
|
||||
@click.stop="onClickDelete"
|
||||
/>
|
||||
</q-card-section>
|
||||
</q-card-section>
|
||||
98
src/modules/timesheets/components/shift-list.vue
Normal file
98
src/modules/timesheets/components/shift-list.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
88
src/modules/timesheets/components/timesheet-wrapper.vue
Normal file
88
src/modules/timesheets/components/timesheet-wrapper.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -1,225 +1,41 @@
|
|||
import { api } from "src/boot/axios";
|
||||
import { isProxy, toRaw } from "vue";
|
||||
import { normalizeExpense, validateExpenseUI } from "../../utils/expenses-validators";
|
||||
import type { ExpenseType } from "../../types/expense.types";
|
||||
import { ExpensesApiError } from "../../types/expense-validation.interface";
|
||||
import type {
|
||||
ExpensePayload,
|
||||
PayPeriodExpenses,
|
||||
TimesheetExpense,
|
||||
UpsertExpenseResult,
|
||||
UpsertExpensesBody,
|
||||
UpsertExpensesResponse
|
||||
} from "../../types/expense.interfaces";
|
||||
import { normalizeObject } from "src/utils/normalize-object";
|
||||
import { useExpensesStore } from "src/stores/expense-store";
|
||||
import { expense_validation_schema } from "src/modules/timesheets/models/expense.validation";
|
||||
import type { Expense, UpsertExpense } from "src/modules/timesheets/models/expense.models";
|
||||
|
||||
/* eslint-disable */
|
||||
const toPlain = <T extends object>(obj:T): T => {
|
||||
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));
|
||||
};
|
||||
export const useExpensesApi = () => {
|
||||
const expenses_store = useExpensesStore();
|
||||
|
||||
const normalizePayload = (expense: ExpensePayload): ExpensePayload => {
|
||||
const exp = normalizeExpense(expense as unknown as TimesheetExpense);
|
||||
const out: ExpensePayload = {
|
||||
date: exp.date,
|
||||
type: exp.type as ExpenseType,
|
||||
comment: exp.comment || '',
|
||||
const toUpsertExpense = (obj: {
|
||||
old_expense?: Expense;
|
||||
new_expense?: Expense;
|
||||
}) => obj as UpsertExpense;
|
||||
|
||||
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
|
||||
export const getPayPeriodExpenses = async (
|
||||
email: string,
|
||||
pay_year: number,
|
||||
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));
|
||||
const updateExpenseByEmployeeEmail = async (employee_email: string, date: string): Promise<void> => {
|
||||
const upsert_expense = toUpsertExpense({
|
||||
old_expense: normalizeObject(expenses_store.initial_expense, expense_validation_schema),
|
||||
new_expense: normalizeObject(expenses_store.current_expense, expense_validation_schema),
|
||||
});
|
||||
await expenses_store.upsertOrDeleteExpensesByEmployeeEmail(employee_email, date, upsert_expense);
|
||||
};
|
||||
|
||||
try {
|
||||
const { data } = await api.get<PayPeriodExpenses>(`/expenses/${encoded_email}/${encoded_year}/${encoded_pay_period_no}`);
|
||||
const deleteExpenseByEmployeeEmail = async (employee_email: string, date: string): Promise<void> => {
|
||||
const upsert_expense = toUpsertExpense({
|
||||
old_expense: normalizeObject(expenses_store.initial_expense, expense_validation_schema),
|
||||
});
|
||||
await expenses_store.upsertOrDeleteExpensesByEmployeeEmail(employee_email, date, upsert_expense);
|
||||
};
|
||||
|
||||
const items = Array.isArray(data.expenses) ? data.expenses.map(normalizeExpense) : [];
|
||||
return {
|
||||
...data,
|
||||
expenses: items,
|
||||
createExpenseByEmployeeEmail,
|
||||
updateExpenseByEmployeeEmail,
|
||||
deleteExpenseByEmployeeEmail,
|
||||
};
|
||||
} 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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
//PUT by email, year and period no
|
||||
export const putPayPeriodExpenses = async (
|
||||
email: string,
|
||||
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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
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 {
|
||||
...(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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
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 }));
|
||||
};
|
||||
|
|
@ -1,141 +1,85 @@
|
|||
import { api } from "src/boot/axios";
|
||||
import { isProxy, toRaw } from "vue";
|
||||
import { DATE_FORMAT_PATTERN, TIME_FORMAT_PATTERN } from "../../constants/shift.constants";
|
||||
import type { ShiftPayload } from "../../types/shift.types";
|
||||
import type { UpsertShiftsBody, UpsertShiftsResponse } from "../../types/shift.interfaces";
|
||||
/* eslint-disable */
|
||||
import { unwrapAndClone } from "src/utils/unwrap-and-clone";
|
||||
import { TIME_FORMAT_PATTERN } from "src/modules/timesheets/constants/shift.constants";
|
||||
import { GenericApiError } from "src/modules/timesheets/models/expense.validation";
|
||||
import { useShiftStore } from "src/stores/shift-store";
|
||||
import { default_shift, type Shift, type UpsertShift } from "src/modules/timesheets/models/shift.models";
|
||||
import { deepEqual } from "src/utils/deep-equal";
|
||||
|
||||
//normalize payload to match backend data
|
||||
export const normalize_comment = (input?: string): string | undefined => {
|
||||
if ( typeof input === 'undefined' || input === null) return undefined;
|
||||
const trimmed = String(input).trim();
|
||||
return trimmed.length ? trimmed : undefined;
|
||||
}
|
||||
export const useShiftApi = () => {
|
||||
const shift_store = useShiftStore();
|
||||
|
||||
const normalizeShiftPayload = (shift: Shift): Shift => {
|
||||
const comment = shift.comment?.trim() || undefined;
|
||||
|
||||
export const normalize_payload = (payload: ShiftPayload): ShiftPayload => {
|
||||
const comment = normalize_comment(payload.comment);
|
||||
return {
|
||||
start_time: payload.start_time,
|
||||
end_time: payload.end_time,
|
||||
type: payload.type,
|
||||
is_remote: Boolean(payload.is_remote),
|
||||
...(comment !== undefined ? { comment } : {}),
|
||||
date: shift.date,
|
||||
start_time: shift.start_time,
|
||||
end_time: shift.end_time,
|
||||
type: shift.type,
|
||||
is_approved: false,
|
||||
is_remote: shift.is_remote,
|
||||
comment: comment,
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const toPlain = <T extends object>(obj: T): T => {
|
||||
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));
|
||||
}
|
||||
|
||||
//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 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.`});
|
||||
|
||||
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 UpsertShiftsError({ status_code: 400, message: `Invalid time value: ${s}.`})
|
||||
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);
|
||||
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({
|
||||
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]: payload }
|
||||
context: { [label]: shift }
|
||||
});
|
||||
}
|
||||
|
||||
if(toMinutes(payload.end_time) <= toMinutes(payload.start_time)) {
|
||||
throw new UpsertShiftsError({
|
||||
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]: payload}
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
|
@ -1,58 +1,53 @@
|
|||
import { useAuthStore } from "src/stores/auth-store";
|
||||
import { useTimesheetStore } from "src/stores/timesheet-store"
|
||||
/* eslint-disable */
|
||||
|
||||
export const useTimesheetApi = () => {
|
||||
const timesheet_store = useTimesheetStore();
|
||||
const auth_store = useAuthStore();
|
||||
const NEXT = 1;
|
||||
const PREVIOUS = -1;
|
||||
|
||||
const getTimesheetsByDate = async (date_string: string) => {
|
||||
const success = await timesheet_store.getPayPeriodByDate(date_string);
|
||||
const getPayPeriodDetailsByDate = async (date_string: string, employee_email?: string) => {
|
||||
const success = await timesheet_store.getPayPeriodByDateOrYearAndNumber(date_string);
|
||||
|
||||
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 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;
|
||||
const getNextOrPreviousPayPeriodDetails = async (direction: number, employee_email?: string) => {
|
||||
const { pay_period } = timesheet_store;
|
||||
let new_number = pay_period.pay_period_no + direction;
|
||||
let new_year = pay_period.pay_year;
|
||||
|
||||
if (new_pay_period_no > 26) {
|
||||
new_pay_period_no = 1;
|
||||
new_pay_year += 1;
|
||||
if (new_number > 26) {
|
||||
new_number = 1;
|
||||
new_year += 1;
|
||||
}
|
||||
|
||||
if (new_pay_period_no < 1) {
|
||||
new_pay_period_no = 26;
|
||||
new_pay_year -= 1;
|
||||
if (new_number < 1) {
|
||||
new_number = 26;
|
||||
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) {
|
||||
await timesheet_store.getTimesheetsByPayPeriodAndEmail(auth_store.user.email);
|
||||
await timesheet_store.getPayPeriodDetailsByEmployeeEmail(employee_email ?? auth_store.user.email);
|
||||
}
|
||||
};
|
||||
|
||||
const getNextPayPeriod = async () => fetchPayPeriod(1);
|
||||
const getPreviousPayPeriod = async () => fetchPayPeriod(-1);
|
||||
const getNextPayPeriodDetails = async (employee_email?: string) => {
|
||||
await getNextOrPreviousPayPeriodDetails(NEXT, employee_email ?? auth_store.user.email);
|
||||
}
|
||||
|
||||
const getPreviousPeriodForUser = async (_employee_email: string) => {
|
||||
await getPreviousPayPeriod();
|
||||
};
|
||||
|
||||
const getNextPeriodForUser = async (_employee_email: string) => {
|
||||
await getNextPayPeriod();
|
||||
};
|
||||
const getPreviousPayPeriodDetails = async (employee_email?: string) => {
|
||||
await getNextOrPreviousPayPeriodDetails(PREVIOUS, employee_email ?? auth_store.user.email);
|
||||
}
|
||||
|
||||
return {
|
||||
getTimesheetsByDate,
|
||||
fetchPayPeriod,
|
||||
// getCurrentPayPeriod,
|
||||
getNextPayPeriod,
|
||||
getPreviousPayPeriod,
|
||||
getPreviousPeriodForUser,
|
||||
getNextPeriodForUser,
|
||||
getPayPeriodDetailsByDate,
|
||||
getNextPayPeriodDetails,
|
||||
getPreviousPayPeriodDetails,
|
||||
};
|
||||
};
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
import { ref, computed } from "vue";
|
||||
import type { TimesheetExpense } from "../types/expense.interfaces";
|
||||
import type { ExpenseType } from "../types/expense.types";
|
||||
import type { Expense } from "src/modules/timesheets/models/expense.models";
|
||||
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 draft = ref<Partial<TimesheetExpense>>({
|
||||
const draft = ref<Partial<Expense>>({
|
||||
date: '',
|
||||
type: DEFAULT_TYPE,
|
||||
comment: '',
|
||||
|
|
|
|||
|
|
@ -1,55 +1,59 @@
|
|||
import { ref, type Ref } from "vue";
|
||||
import { normalizeExpense, validateExpenseUI } from "../utils/expenses-validators";
|
||||
import { normExpenseType } from "../utils/expense.util";
|
||||
import type { TimesheetExpense } from "../types/expense.interfaces";
|
||||
// import { ref, type Ref } from "vue";
|
||||
// import { normalizeObject } from "src/utils/normalize-object";
|
||||
// import { normExpenseType } from "../utils/expense.util";
|
||||
// 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 = {
|
||||
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 expenses_store = useExpensesStore();
|
||||
|
||||
const addFromDraft = () => {
|
||||
const candidate: TimesheetExpense = 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);
|
||||
// export const useExpenseItems = () => {
|
||||
// let expenses = unwrapAndClone(expenses_store.pay_period_expenses.expenses.map(normalizeExpense));
|
||||
|
||||
validateExpenseUI(candidate, 'expense_draft');
|
||||
items.value = [ ...items.value, candidate];
|
||||
};
|
||||
// const normalizePayload = (expense: Expense): Expense => {
|
||||
// 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 locked = typeof is_approved === 'boolean' ? is_approved : is_approved.value;
|
||||
if(locked) return;
|
||||
if(index < 0 || index >= items.value.length) return;
|
||||
items.value = items.value.filter((_,i)=> i !== index);
|
||||
};
|
||||
// const addFromDraft = () => {
|
||||
// const candidate: Expense = normalizeExpense({
|
||||
// date: draft.date,
|
||||
// type: normExpenseType(draft.type),
|
||||
// ...(typeof draft.amount === 'number' ? { amount: draft.amount }: {}),
|
||||
// ...(typeof draft.mileage === 'number' ? { mileage: draft.mileage }: {}),
|
||||
// comment: String(draft.comment ?? '').trim(),
|
||||
// } as Expense);
|
||||
|
||||
const validateAll = () => {
|
||||
for (const expense of items.value) {
|
||||
validateExpenseUI(expense, 'expense_item');
|
||||
}
|
||||
};
|
||||
// validateExpenseUI(candidate, 'expense_draft');
|
||||
// expenses = [ ...expenses, candidate];
|
||||
// };
|
||||
|
||||
const payload = () => items.value.map(normalizeExpense);
|
||||
// const removeAt = (index: number) => {
|
||||
// if(index < 0 || index >= expenses.length) return;
|
||||
// expenses = expenses.filter((_,i)=> i !== index);
|
||||
// };
|
||||
|
||||
return {
|
||||
items,
|
||||
addFromDraft,
|
||||
removeAt,
|
||||
validateAll,
|
||||
payload,
|
||||
};
|
||||
};
|
||||
// const validateAll = () => {
|
||||
// for (const expense of expenses) {
|
||||
// validateExpenseUI(expense, 'expense_item');
|
||||
// }
|
||||
// };
|
||||
|
||||
// const payload = () => expenses.map(normalizeExpense);
|
||||
|
||||
// return {
|
||||
// expenses,
|
||||
// addFromDraft,
|
||||
// removeAt,
|
||||
// validateAll,
|
||||
// payload,
|
||||
// };
|
||||
// };
|
||||
|
|
|
|||
|
|
@ -1,3 +1 @@
|
|||
export const COMMENT_MAX_LENGTH = 280;
|
||||
|
||||
export const DATE_FORMAT_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
||||
45
src/modules/timesheets/models/expense.models.ts
Normal file
45
src/modules/timesheets/models/expense.models.ts
Normal 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: [],
|
||||
}
|
||||
77
src/modules/timesheets/models/expense.validation.ts
Normal file
77
src/modules/timesheets/models/expense.validation.ts
Normal 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 }
|
||||
});
|
||||
}
|
||||
78
src/modules/timesheets/models/pay-period-details.models.ts
Normal file
78
src/modules/timesheets/models/pay-period-details.models.ts
Normal 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: "",
|
||||
}
|
||||
50
src/modules/timesheets/models/shift.models.ts
Normal file
50
src/modules/timesheets/models/shift.models.ts
Normal 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,
|
||||
};
|
||||
|
|
@ -1,5 +1,3 @@
|
|||
export type FormMode = 'create' | 'edit' | 'delete';
|
||||
|
||||
export type PayPeriodLabel = {
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
|
|
@ -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>
|
||||
50
src/modules/timesheets/services/timesheet-service.ts
Normal file
50
src/modules/timesheets/services/timesheet-service.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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[];
|
||||
}
|
||||
|
|
@ -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[];
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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[];
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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),
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -1,9 +1,8 @@
|
|||
import type { TimesheetExpense } from "../types/expense.interfaces";
|
||||
import type { ExpenseSavePayload, ExpenseTotals, ExpenseType } from "../types/expense.types";
|
||||
/* eslint-disable */
|
||||
import type { Expense, ExpenseTotals } from "src/modules/timesheets/models/expense.models";
|
||||
|
||||
//------------------ normalization / icons ------------------
|
||||
export const normExpenseType = (type: unknown): string =>
|
||||
String(type ?? '').trim().toUpperCase();
|
||||
typeof type === 'string' ? type.trim().toUpperCase() : '';
|
||||
|
||||
const icon_map: Record<string,string> = {
|
||||
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 ------------------
|
||||
export const computeExpenseTotals = (items: readonly TimesheetExpense[]): ExpenseTotals =>
|
||||
export const computeExpenseTotals = (items: readonly Expense[]): ExpenseTotals =>
|
||||
items.reduce<ExpenseTotals>(
|
||||
(acc, e) => ({
|
||||
amount: acc.amount + (Number(e.amount) || 0),
|
||||
|
|
@ -40,7 +31,7 @@ export const computeExpenseTotals = (items: readonly TimesheetExpense[]): Expens
|
|||
);
|
||||
|
||||
//------------------ 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 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 commentRequired = (val: unknown) => (String(val ?? '').trim().length > 0) || t('timesheet.expense.errors.comment_required');
|
||||
|
||||
const commentTooLong = (val: unknown) => (String(val ?? '').trim().length <= max_comment_char) || t('timesheet.expense.errors.comment_too_long');
|
||||
const commentRequired = (val: unknown) => (typeof val === 'string' ? val.trim().length > 0 : false ) || t('timesheet.expense.errors.comment_required');
|
||||
|
||||
return {
|
||||
typeRequired,
|
||||
amountRequired,
|
||||
mileageRequired,
|
||||
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,
|
||||
});
|
||||
|
|
@ -1,117 +1,130 @@
|
|||
import { COMMENT_MAX_LENGTH, DATE_FORMAT_PATTERN } from "../constants/expense.constants";
|
||||
import { ExpensesValidationError } from "../types/expense-validation.interface";
|
||||
import type { TimesheetExpense } from "../types/expense.interfaces";
|
||||
import {
|
||||
type ExpenseType,
|
||||
TYPES_WITH_AMOUNT_ONLY,
|
||||
TYPES_WITH_MILEAGE_ONLY
|
||||
} from "../types/expense.types";
|
||||
// import { COMMENT_MAX_LENGTH, DATE_FORMAT_PATTERN } from "src/modules/timesheets/constants/expense.constants";
|
||||
// import { ExpensesValidationError } from "src/modules/timesheets/models/expense.validation";
|
||||
// import { type Expense, type ExpenseType, TYPES_WITH_AMOUNT_ONLY, TYPES_WITH_MILEAGE_ONLY } from "src/modules/timesheets/models/expense.models";
|
||||
|
||||
//normalization helpers
|
||||
export const toNumOrUndefined = (value: unknown): number | undefined => {
|
||||
if(value === undefined || value === null || (typeof value === 'string' && value.trim() === '')) return undefined;
|
||||
const num = Number(value);
|
||||
return Number.isFinite(num) ? num : undefined;
|
||||
};
|
||||
// //normalization helpers
|
||||
// export const toNumOrUndefined = (value: unknown): number | undefined => {
|
||||
// if(value === undefined || value === null || (typeof value === 'string' && value.trim() === '')) return undefined;
|
||||
// const num = Number(value);
|
||||
|
||||
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;
|
||||
};
|
||||
// return Number.isFinite(num) ? num : undefined;
|
||||
// };
|
||||
|
||||
export const normalizeType = (input: string): string => (input ?? '').trim().toUpperCase();
|
||||
// export const normalizeComment = (input?: string): string | undefined => {
|
||||
// if(typeof input === 'undefined' || input === null) return undefined;
|
||||
// const trimmed = String(input).trim();
|
||||
|
||||
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 }: {} ),
|
||||
};
|
||||
};
|
||||
// return trimmed.length ? trimmed : undefined;
|
||||
// };
|
||||
|
||||
//UI validation error messages
|
||||
export const validateExpenseUI = (raw: TimesheetExpense, label: string = 'expense'): void => {
|
||||
const expense = normalizeExpense(raw);
|
||||
// export const normalizeType = (input: string): string => (input ?? '').trim().toUpperCase();
|
||||
|
||||
//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 },
|
||||
});
|
||||
}
|
||||
// export const normalizeExpense = (expense: Expense): Expense => {
|
||||
// const comment = normalizeComment(expense.comment);
|
||||
// const amount = toNumOrUndefined(expense.amount);
|
||||
// const mileage = toNumOrUndefined(expense.mileage);
|
||||
|
||||
//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 } },
|
||||
});
|
||||
}
|
||||
// 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 }: {} ),
|
||||
// };
|
||||
// };
|
||||
|
||||
//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 },
|
||||
});
|
||||
}
|
||||
// //UI validation error messages
|
||||
// export const validateExpenseUI = (raw: Expense, label: string = 'expense'): void => {
|
||||
// const expense = normalizeExpense(raw);
|
||||
|
||||
//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 },
|
||||
});
|
||||
}
|
||||
// //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 },
|
||||
// });
|
||||
// }
|
||||
|
||||
//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;
|
||||
// //comment input validation
|
||||
// if(!expense.comment) {
|
||||
// throw new ExpensesValidationError({
|
||||
// status_code: 400,
|
||||
// message: 'timesheet.expense.errors.comment_required',
|
||||
// context: { [label]: expense },
|
||||
// })
|
||||
// }
|
||||
|
||||
if(has_amount === has_mileage) {
|
||||
throw new ExpensesValidationError({
|
||||
status_code: 400,
|
||||
message: 'timesheet.expense.errors.amount_xor_mileage',
|
||||
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 } },
|
||||
// });
|
||||
// }
|
||||
|
||||
//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 },
|
||||
});
|
||||
}
|
||||
};
|
||||
// //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
|
||||
|
|
|
|||
|
|
@ -1,19 +1,18 @@
|
|||
import type { ShiftKey, ShiftPayload, ShiftSelectOption } from "../types/shift.types";
|
||||
/* eslint-disable */
|
||||
// import type { ShiftKey, ShiftPayload, ShiftSelectOption } from "../types/shift.types";
|
||||
|
||||
export const toShiftPayload = (shift: any): ShiftPayload => ({
|
||||
start_time: String(shift.start_time),
|
||||
end_time: String(shift.end_time),
|
||||
type: String(shift.type).toUpperCase() as ShiftKey,
|
||||
is_remote: !!shift.is_remote,
|
||||
...(shift.comment ? { comment: String(shift.comment) } : {}),
|
||||
});
|
||||
// export const toShiftPayload = (shift: any): ShiftPayload => ({
|
||||
// start_time: String(shift.start_time),
|
||||
// end_time: String(shift.end_time),
|
||||
// type: String(shift.type).toUpperCase() as ShiftKey,
|
||||
// is_remote: !!shift.is_remote,
|
||||
// ...(shift.comment ? { comment: String(shift.comment) } : {}),
|
||||
// });
|
||||
|
||||
export const buildShiftOptions = (
|
||||
keys: readonly string[],
|
||||
t:(k: string) => string
|
||||
): ShiftSelectOption[] =>
|
||||
keys.map((key) => ({
|
||||
value: key as any,
|
||||
label: t(`timesheet.shift.types.${key}`),
|
||||
}));
|
||||
// export const buildShiftOptions = (
|
||||
// keys: readonly string[],
|
||||
// t:(k: string) => string
|
||||
// ): ShiftSelectOption[] =>
|
||||
// keys.map((key) => ({
|
||||
// value: key as any,
|
||||
// label: t(`timesheet.shift.types.${key}`),
|
||||
// }));
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import type { PayPeriodLabel } from "../types/ui.types";
|
||||
import type { PayPeriodLabel } from "src/modules/timesheets/models/ui.models";
|
||||
|
||||
export const formatPayPeriodLabel = (
|
||||
raw_label: string | undefined,
|
||||
|
|
|
|||
|
|
@ -1,20 +1,17 @@
|
|||
<script setup lang="ts">
|
||||
/* eslint-disable */
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-layout view="hHh lpR fFf">
|
||||
<q-page-container>
|
||||
<q-page padding class="column justify-center items-center bg-secondary">
|
||||
<q-card class="col-shrink rounded-20">
|
||||
<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
|
||||
class="absolute-bottom text-h4 text-center text-weight-bolder justify-center items-center row">
|
||||
<div class="q-pr-md text-primary text-h3 text-weight-bolder">404</div>
|
||||
PAGE NOT FOUND
|
||||
</div>
|
||||
</q-img>
|
||||
<q-card-section class="text-center text-h5 text-primary">
|
||||
{{$t('notFoundPage.pageText')}}
|
||||
{{ $t('notFoundPage.pageText') }}
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-page>
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user