refactor(approvals): so many changes that are difficult to keep track of. Work on more integration between approvals and timesheet, add list view to approvals, etc

This commit is contained in:
Nicolas Drolet 2025-10-10 17:04:14 -04:00
parent dc615340bc
commit 7f43341629
49 changed files with 985 additions and 658 deletions

View File

@ -25,7 +25,7 @@
}
body.body--dark {
--q-secondary: #0f1114;
--q-secondary: #2b2f34;
color: $grey-2;
}
@ -33,3 +33,7 @@ body.body--dark {
--q-dark: #FFF;
color: $blue-grey-8;
}
.shift-highlight {
background: #0195462a;
}

View File

@ -16,16 +16,16 @@ $primary : #019547;
$secondary : #DAE0E7;
$accent : #AAD5C4;
$dark-shadow-color : #019547;
$dark-shadow-color : #00220f;
$elevation-dark-umbra : rgba($dark-shadow-color, 0.4);
$elevation-dark-penumbra : rgba($dark-shadow-color, 0);
$elevation-dark-ambient : rgba($dark-shadow-color, 0);
$elevation-dark-umbra : rgba($dark-shadow-color, 1);
$elevation-dark-penumbra : rgba($dark-shadow-color, 0.2);
$elevation-dark-ambient : rgba($dark-shadow-color, 0.2);
$dark-shadow-2 : 0 3px 5px -1px $elevation-dark-umbra, 0 5px 8px $elevation-dark-penumbra, 0 1px 14px $elevation-dark-ambient;
$layout-shadow-dark : 0 0 10px 5px rgba($dark-shadow-color, 0.5);
$dark : #333;
$dark : #42444b;
$dark-page : #343434;
$positive : #21ba45;

View File

@ -4,6 +4,7 @@
import { useUiStore } from 'src/stores/ui-store';
import { ref } from 'vue';
import { RouteNames } from 'src/router/router-constants';
import { CAN_APPROVE_PAY_PERIODS } from 'src/modules/shared/models/user.models';
const authStore = useAuthStore();
const uiStore = useUiStore();
@ -50,7 +51,7 @@
<!-- Timesheet Validation -- Supervisor and Accounting only -->
<q-item v-ripple clickable side @click="goToPageName(RouteNames.TIMESHEET_APPROVALS)"
v-if="['supervisor', 'accounting'].includes(authStore.user.role)">
v-if="CAN_APPROVE_PAY_PERIODS.includes(authStore.user.role)">
<q-item-section avatar>
<q-icon name="event_available" color="primary" />
</q-item-section>
@ -61,7 +62,7 @@
<!-- Employee List -- Supervisor, Accounting and HR only -->
<q-item v-ripple clickable side @click="goToPageName(RouteNames.EMPLOYEE_LIST)"
v-if="['supervisor', 'accounting', 'human_resources'].includes(authStore.user.role)">
v-if="CAN_APPROVE_PAY_PERIODS.includes(authStore.user.role)">
<q-item-section avatar>
<q-icon name="view_list" color="primary" />
</q-item-section>
@ -72,7 +73,7 @@
<!-- Employee Timesheet temp -- Employee, Supervisor, Accounting only -->
<q-item v-ripple clickable side @click="goToPageName(RouteNames.TIMESHEET_TEMP)"
v-if="['supervisor', 'accounting', 'employee'].includes(authStore.user.role)">
v-if="CAN_APPROVE_PAY_PERIODS.includes(authStore.user.role)">
<q-item-section avatar>
<q-icon name="punch_clock" color="primary" />
</q-item-section>

View File

@ -1,11 +1,12 @@
<script setup lang="ts">
import { UserRole } from 'src/modules/shared/models/user.models';
import { useAuthApi } from '../composables/use-auth-api';
import { useRouter } from 'vue-router';
const auth_api = useAuthApi();
const router = useRouter();
const setBypassUser = (bypassRole: string) => {
const setBypassUser = (bypassRole: UserRole) => {
auth_api.setUser(bypassRole);
router.push({ name: 'dashboard' }).catch( err => {
@ -19,7 +20,7 @@
<q-card-section class="q-pa-sm text-uppercase text-center"> impersonate </q-card-section>
<q-card-actions vertical>
<q-btn
v-for="role, index in [ 'supervisor', 'accounting', 'human_resources', 'employee' ]"
v-for="role, index in UserRole"
:key="index"
push
color="primary"

View File

@ -1,10 +1,9 @@
import { useAuthStore } from "../../../stores/auth-store";
import type { UserRole } from "src/modules/shared/models/user.models";
export const useAuthApi = () => {
const authStore = useAuthStore();
const login = () => {
authStore.login();
};
@ -21,7 +20,7 @@ export const useAuthApi = () => {
return authStore.isAuthorizedUser;
};
const setUser = (bypassRole: string) => {
const setUser = (bypassRole: UserRole) => {
authStore.setUser(bypassRole);
}

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import type { EmployeeListTableItem } from 'src/modules/employee-list/types/employee-list-table-interface';
import type { EmployeeProfile } from 'src/modules/employee-list/models/employee-profile.models';
// const getEmployeeAvatar = (first_name: string, last_name: string) => {
// // add logic here to see if user has an avatar image and return that instead of initials
@ -7,7 +7,7 @@
// };
const { row } = defineProps<{
row: EmployeeListTableItem
row: EmployeeProfile
}>()
const emit = defineEmits<{
onProfileClick: [email: string]

View File

@ -3,9 +3,8 @@
import { useEmployeeListApi } from 'src/modules/employee-list/composables/use-employee-api';
import { useEmployeeStore } from 'src/stores/employee-store';
import { useI18n } from 'vue-i18n';
import SupervisorCrewTableItem from './supervisor-crew-table-item.vue';
import type { EmployeeListTableItem } from '../../types/employee-list-table-interface';
import EmployeeListTableItem from 'src/modules/employee-list/components/employee-list-table-item.vue';
import type { EmployeeProfile } from 'src/modules/employee-list/models/employee-profile.models';
import type { QTableColumn } from 'quasar';
const employee_list_api = useEmployeeListApi();
@ -17,7 +16,7 @@
const is_grid_mode = ref(true);
const pagination = ref({ rowsPerPage: 0 });
const employee_list_columns = computed((): QTableColumn<EmployeeListTableItem>[] => [
const employee_list_columns = computed((): QTableColumn<EmployeeProfile>[] => [
{name: 'first_name', label: t('employee_list.table.first_name'), field: 'first_name', align: 'left'},
{name: 'last_name', label: t('employee_list.table.last_name'), field: 'last_name', align: 'left'},
{name: 'email', label: t('employee_list.table.email'), field: 'email', align: 'left'},
@ -49,7 +48,7 @@
:rows-per-page-options="[0]"
:filter="filter"
class="q-pa-md bg-transparent"
:class="is_grid_mode ? '': 'my-sticky-header-table'"
:class="is_grid_mode ? '': 'sticky-header-table'"
:table-class="$q.dark.isActive ? 'q-px-md q-py-none q-mx-md rounded-10 bg-dark' : 'q-px-md q-py-none q-mx-md rounded-10 bg-white'"
color="primary"
table-header-class="text-primary text-uppercase"
@ -62,7 +61,7 @@
@row-click="() => console.log('click!')"
>
<template v-slot:item="props">
<SupervisorCrewTableItem :row="props.row"/>
<EmployeeListTableItem :row="props.row"/>
</template>
<template v-slot:top>
@ -126,7 +125,7 @@
</template>
<style lang="sass">
.my-sticky-header-table
.sticky-header-table
thead tr:first-child th
background-color: var(--q-dark)
margin-top: none

View File

@ -0,0 +1,14 @@
import { api } from 'src/boot/axios';
import type { EmployeeProfile } from 'src/modules/employee-list/models/employee-profile.models';
export const EmployeeListService = {
getEmployeeList: async (): Promise<EmployeeProfile[]> => {
const response = await api.get<EmployeeProfile[]>('/employees/employee-list')
return response.data;
},
getEmployeeDetails: async (email: string): Promise<EmployeeProfile> => {
const response = await api.get<EmployeeProfile>('employees/profile/' + email);
return response.data;
},
};

View File

@ -1,17 +0,0 @@
// /* eslint-disable */
import { api } from 'src/boot/axios';
import type { EmployeeListTableItem } from '../types/employee-list-table-interface';
import type { EmployeeProfile } from '../types/employee-profile-interface';
export const EmployeeListService = {
getEmployeeList: async (): Promise<EmployeeListTableItem[]> => {
const response = await api.get<EmployeeListTableItem[]>('/employees/employee-list')
return response.data;
},
getEmployeeDetails: async (email: string): Promise<EmployeeProfile> => {
const response = await api.get<EmployeeProfile>('employees/profile/' + email);
return response.data;
},
};

View File

@ -1,8 +0,0 @@
export interface EmployeeListTableItem {
first_name: string;
last_name: string;
email: string;
supervisor_full_name: string | null;
company_name: number;
job_title: string;
};

View File

@ -3,7 +3,7 @@
import { deepEqual } from 'src/utils/deep-equal';
import ProfileInputField from 'src/modules/profile/components/shared/profile-panel-input-field.vue';
import ProfileSelectField from 'src/modules/profile/components/shared/profile-panel-select-field.vue';
import { type EmployeeProfile } from 'src/modules/employee-list/types/employee-profile-interface';
import type { EmployeeProfile } from 'src/modules/employee-list/models/employee-profile.models';
const { employeeProfile } = defineProps<{
employeeProfile: EmployeeProfile;

View File

@ -2,7 +2,7 @@
import { ref } from 'vue';
import { deepEqual } from 'src/utils/deep-equal';
import ProfileInputField from 'src/modules/profile/components/shared/profile-panel-input-field.vue';
import { type EmployeeProfile } from 'src/modules/employee-list/types/employee-profile-interface';
import type { EmployeeProfile } from 'src/modules/employee-list/models/employee-profile.models';
const { employeeProfile } = defineProps<{
employeeProfile: EmployeeProfile;

View File

@ -3,7 +3,7 @@
import PanelInfoEmployee from 'src/modules/profile/components/employee/profile-panel-info-employee.vue';
import PanelPreferences from 'src/modules/profile/components/shared/profile-panel-preferences.vue';
import ProfileTabMenuTemplate from 'src/modules/profile/components/shared/profile-tab-menu-template.vue';
import { default_employee_profile, type EmployeeProfile } from 'src/modules/employee-list/types/employee-profile-interface';
import { default_employee_profile, type EmployeeProfile } from 'src/modules/employee-list/models/employee-profile.models';
const PanelNames = {
PERSONAL_INFO: 'personal_info',

View File

@ -1,6 +1,26 @@
/* eslint-disable */
export interface User {
firstName: string;
lastName: string;
email: string;
role: string;
role: UserRole;
}
export enum UserRole {
ADMIN = 'ADMIN',
SUPERVISOR = 'SUPERVISOR',
HR = 'HR',
ACCOUNTING = 'ACCOUNTING',
EMPLOYEE = 'EMPLOYEE',
DEALER = 'DEALER',
CUSTOMER = 'CUSTOMER',
GUEST = 'GUEST',
}
export const CAN_APPROVE_PAY_PERIODS: UserRole[] = [
UserRole.ADMIN,
UserRole.SUPERVISOR,
UserRole.HR,
UserRole.ACCOUNTING,
]

View File

@ -27,6 +27,7 @@
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);
console.log('costs, ', all_costs);
const all_mileage = all_days.map(day => day.total_mileage);

View File

@ -15,14 +15,14 @@
ChartJS.defaults.maintainAspectRatio = false;
ChartJS.defaults.color = $q.dark.isActive ? '#F5F5F5' : '#616161';
const { pay_period_details } = useTimesheetStore();
const timesheet_store = useTimesheetStore();
const hours_worked_labels = ref<string[]>([]);
const hours_worked_dataset = ref<ChartDataset<'bar'>[]>([]);
const getHoursWorkedData = (): ChartData<'bar'> => {
const all_days = pay_period_details.weeks.flatMap( week => Object.values(week.shifts));
const all_days = timesheet_store.pay_period_details.weeks.flatMap( week => Object.values(week.shifts));
const datasetConfig = [
{
key: 'regular_hours',

View File

@ -25,9 +25,9 @@
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,
current_pay_period_overview.other_hours.evening_hours,
current_pay_period_overview.other_hours.emergency_hours,
current_pay_period_overview.other_hours.overtime_hours,
],
backgroundColor: [
colors.getPaletteColor('green-5'), // Regular
@ -39,9 +39,9 @@
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',
current_pay_period_overview.other_hours.evening_hours.toString() + 'h',
current_pay_period_overview.other_hours.emergency_hours.toString() + 'h',
current_pay_period_overview.other_hours.overtime_hours.toString() + 'h',
]

View File

@ -2,35 +2,44 @@
setup
lang="ts"
>
import { ref } from 'vue';
import { provide, 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';
import ExpenseCrudDialogList from 'src/modules/timesheets/components/expense-crud-dialog-list.vue';
defineProps<{
const { employeeEmail } = defineProps<{
employeeEmail: string;
}>();
const dialog_model = defineModel<boolean>('dialog', { default: false });
const timesheet_store = useTimesheetStore();
const render_key = ref(1);
// const timesheet_store = useTimesheetStore();
const is_showing_graph = ref(true);
provide('employeeEmail', employeeEmail);
</script>
<template>
<q-dialog
v-model="dialog_model"
full-width
full-height
transition-show="jump-down"
transition-hide="jump-down"
@show="render_key += 1"
>
<!-- loader -->
<transition
enter-active-class="animated faster zoomIn"
leave-active-class="animated faster zoomOut"
mode="out-in"
>
<q-card
v-if="timesheet_store.is_loading"
class="column flex-center text-center"
style="width: 50vw !important; max-height: 50vh !important;"
>
<q-spinner
color="primary"
@ -39,90 +48,64 @@
class="col-auto"
/>
<div class="col-auto text-primary text-h6 text-weight-bold text-center ">
{{ $t('shared.loading') }}
{{ $t('shared.label.loading') }}
</div>
</q-card>
</transition>
<q-card
v-else
v-if="!timesheet_store.is_loading"
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"
class="text-h5 text-weight-bolder text-center bg-primary q-pa-none text-uppercase text-white 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 -->
<!-- employee pay period details using chart -->
<q-card-section
v-if="!is_showing_graph"
class="q-pa-none"
:horizontal="!$q.screen.lt.md"
class=" col-auto q-px-sm no-wrap"
>
<DetailedDialogChartHoursWorked
:key="render_key"
class="col"
/>
<DetailedDialogChartShiftTypes
:key="render_key + 1"
class="col-2 q-ma-lg"
/>
<DetailedDialogChartExpenses
:key="render_key + 2"
class="col"
/>
</q-card-section>
<q-card-section class="col-auto">
<q-separator />
<ExpenseCrudDialogList
horizontal
:employee-email="employeeEmail"
/>
<q-separator />
</q-card-section>
<!-- list of shifts -->
<q-card-section
:horizontal="$q.screen.gt.sm"
class="q-pa-none rounded-10"
class="col-auto q-px-sm rounded-5 no-wrap"
>
<TimesheetWrapper
dense
:employee-email="employeeEmail"
/>
</q-card-section>
</q-card-section>
<!-- employee timesheet details with chart -->
<q-card-section
v-if="is_showing_graph"
class="q-pa-md col column full-width no-wrap"
>
<q-card-section
:horizontal="!$q.screen.lt.md"
class="q-pa-none col no-wrap"
style="min-height: 300px;"
>
<DetailedDialogChartHoursWorked class="col-7" />
<q-separator
spaced
:vertical="!$q.screen.lt.md"
/>
<div class="column col justify-center no-wrap q-pa-none">
<DetailedDialogChartShiftTypes class="col-5" />
<q-separator
spaced
:vertical="!$q.screen.lt.md"
/>
<DetailedDialogChartExpenses class="col" />
</div>
</q-card-section>
</q-card-section>
</q-card>
</q-dialog>
</template>

View File

@ -1,4 +1,7 @@
<script setup lang="ts">
<script
setup
lang="ts"
>
import type { PayPeriodOverview } from 'src/modules/timesheet-approval/models/pay-period-overview.models';
const modelApproval = defineModel<boolean>();
@ -6,16 +9,16 @@
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">
<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 -->
@ -42,35 +45,33 @@
<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">
<q-card-section class="q-py-none q-px-sm q-my-sm">
<div class="row">
<!-- left portion of pay period card -->
<div class="col column no-wrap q-px-sm">
<div class="col column 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>
<div class="col column">
<span class="text-weight-bold text-primary text-uppercase q-pa-none q-my-none"> {{ $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" />
</div>
<!-- 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 class="col-auto row ellipsis q-mt-xs">
<div
v-for="hour_type, index in row.other_hours"
:key="index"
class="col-4 column ellipsis"
:class="hour_type === 0 ? 'invisible' : ''"
>
<span
class="text-weight-bold text-primary text-uppercase q-pa-none q-my-none"
style="font-size: 0.7em;"
> {{ $t(`shared.shift_type.${index.replace('_hours', '')}`) }} </span>
<span
class="text-weight-bolder q-pa-none q-mb-xs"
style="font-size: 1.2em; line-height: 1em;"
> {{ hour_type }} </span>
</div>
</div>
</div>
@ -83,19 +84,34 @@
<!-- 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>
<span
class="text-weight-bold text-primary text-uppercase q-pa-none q-my-none"
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>
<span
class="text-weight-bold text-primary text-uppercase q-pa-none q-my-none"
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" />
<q-separator
color="primary"
size="2px"
/>
<!-- Validate Pay Period section -->
<q-card-section

View File

@ -3,14 +3,22 @@
lang="ts"
>
import { computed, ref } from 'vue';
import { useExpensesStore } from 'src/stores/expense-store';
import { useTimesheetStore } from 'src/stores/timesheet-store';
import { useTimesheetApprovalApi } from 'src/modules/timesheet-approval/composables/use-timesheet-approval-api';
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 expenses_store = useExpensesStore();
const timesheet_store = useTimesheetStore();
const timesheet_approval_api = useTimesheetApprovalApi();
const filter = ref<string | number | null>('');
const is_grid_mode = ref(true);
const IS_ABNORMAL_SHIFT = ['OVERTIME', 'EMERGENCY'];
const IS_PTO = ['HOLIDAY', 'VACATION', 'SICK'];
const employeeEmail = defineModel();
@ -25,10 +33,24 @@
const onClickedDetails = async (employee_email: string, row: PayPeriodOverview) => {
employeeEmail.value = employee_email;
emit('clickedDetailsButton', employee_email);
timesheet_store.current_pay_period_overview = row;
emit('clickedDetailsButton', employee_email);
await timesheet_store.getPayPeriodDetailsByEmployeeEmail(employee_email);
await expenses_store.getPayPeriodExpensesByEmployeeEmail(employee_email);
};
const getListModeTextColor = (type: string): string => {
console.log('type: ', type);
if (IS_ABNORMAL_SHIFT.includes(type)) {
return ' text-negative text-weight-bolder';
}
else if (IS_PTO.includes(type)) {
return ' text-warning text-weight-bold';
}
return '';
}
</script>
<template>
@ -38,13 +60,16 @@
:columns="pay_period_overview_columns"
row-key="email"
:filter="filter"
grid
:grid="is_grid_mode"
dense
hide-pagination
color="primary"
:rows-per-page-options="[0]"
card-container-class="justify-center"
:loading="timesheet_store.is_loading"
class="q-py-md bg-transparent"
:class="is_grid_mode ? '' : 'sticky-header-table no-shadow'"
table-class="q-pa-none q-py-none q-mx-md rounded-10 bg-dark shadow-4'"
:no-data-label="$t('shared.error.no_data_found')"
:no-results-label="$t('shared.error.no_search_results')"
:loading-label="$t('shared.label.loading')"
@ -54,16 +79,67 @@
class="full-width"
:class="$q.screen.lt.md ? 'text-center' : 'row'"
>
<PayPeriodNavigator />
<PayPeriodNavigator
@date-selected="timesheet_approval_api.getPayPeriodOverviewsByDateOrYearAndNumber"
/>
<q-space />
<!-- Grid-or-List toggle goes here -->
<q-btn-toggle
v-model="is_grid_mode"
push
color="white"
text-color="primary"
toggle-color="primary"
class="q-mr-md"
:options="[
{ icon: 'grid_view', value: true },
{ icon: 'view_list', value: false },
]"
/>
<QTableFilters v-model="filter" />
</div>
</template>
<template #header="props">
<q-tr
:props="props"
class="bg-primary"
>
<q-th
v-for="col in props.cols"
:key="col.name"
:props="props"
>
<span
v-if="col.label !== 'timesheet_approvals.table.is_approved'"
class="text-uppercase text-weight-bolder text-white"
>
{{ $t(col.label) }}
</span>
</q-th>
</q-tr>
</template>
<template #body-cell="props">
<q-td
:props="props"
class="text-weight-medium"
>
<span
v-if="(props.value > 0 && typeof props.value !== 'boolean') || typeof props.value === 'string'"
:class="getListModeTextColor(props.col.name)"
>{{ props.value }}</span>
<q-icon
v-if="typeof props.value === 'boolean'"
:name="props.value ? 'verified' : 'fiber_manual_record'"
:color="props.value ? 'primary' : 'grey-5'"
size="sm"
/>
</q-td>
</template>
<!-- Template for individual employee cards -->
<template #item="props: { row: PayPeriodOverview, key: string }">
<OverviewListItem
@ -89,3 +165,22 @@
</q-table>
</div>
</template>
<style lang="sass">
.sticky-header-table
thead tr:first-child th
background-color: var(--q-primary)
margin-top: none
thead tr th
position: sticky
z-index: 1
thead tr:first-child th
top: 0
&.q-table--loading thead tr:last-child th
top: 48px
tbody
scroll-margin-top: 48px
</style>

View File

@ -1,13 +1,16 @@
import { useTimesheetStore } from "src/stores/timesheet-store";
import { useAuthStore } from "src/stores/auth-store";
import type { TimesheetApprovalCSVReportFilters } from "src/modules/timesheet-approval/models/timesheet-approval-csv-report.models";
import { NavigatorConstants } from "src/modules/timesheet-approval/models/pay-period-overview.models";
export const useTimesheetApprovalApi = () => {
const timesheet_store = useTimesheetStore();
const auth_store = useAuthStore();
const getPayPeriodOverviewsByDate = async (date_string: string): Promise<void> => {
const success = await timesheet_store.getPayPeriodByDateOrYearAndNumber(date_string);
const getPayPeriodOverviewsByDateOrYearAndNumber = async (date_or_year: string | number, period_number?: number): Promise<void> => {
let success = false;
if (typeof date_or_year === 'string') success = await timesheet_store.getPayPeriodByDateOrYearAndNumber(date_or_year);
else if (typeof date_or_year === 'number' && period_number) success = await timesheet_store.getPayPeriodByDateOrYearAndNumber(date_or_year, period_number);
if (success) {
await timesheet_store.getPayPeriodOverviewsBySupervisorEmail(
@ -18,6 +21,26 @@ export const useTimesheetApprovalApi = () => {
}
};
const getNextOrPreviousPayPeriodOverview = async (direction: number) => {
let new_period_number = timesheet_store.pay_period.pay_period_no + direction;
let new_year = timesheet_store.pay_period.pay_year;
if ( new_period_number > 26 || new_period_number < 1) {
new_period_number = 1;
new_year += direction;
}
await getPayPeriodOverviewsByDateOrYearAndNumber(new_year, new_period_number);
};
const getNextPayPeriodOverview = async () => {
await getNextOrPreviousPayPeriodOverview(NavigatorConstants.NEXT_PERIOD);
};
const getPreviousPayPeriodOverview = async () => {
await getNextOrPreviousPayPeriodOverview(NavigatorConstants.PREVIOUS_PERIOD);
};
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;
@ -34,7 +57,9 @@ export const useTimesheetApprovalApi = () => {
};
return {
getPayPeriodOverviewsByDate,
getPayPeriodOverviewsByDateOrYearAndNumber,
getTimesheetApprovalCSVReport,
getNextPayPeriodOverview,
getPreviousPayPeriodOverview,
}
};

View File

@ -1,15 +1,28 @@
import type { QTableColumn } from "quasar";
/* eslint-disable */
export enum NavigatorConstants {
NEXT_PERIOD = 1,
PREVIOUS_PERIOD = -1,
}
export interface PayPeriodOverview {
email: string;
employee_name: string;
regular_hours: number;
other_hours: {
evening_hours: number;
emergency_hours: number;
overtime_hours: number;
sick_hours: number;
holiday_hours: number;
vacation_hours: number;
};
total_hours: number;
expenses: number;
mileage: number;
is_approved: boolean;
};
}
export interface PayPeriodOverviewResponse {
pay_period_no: number;
@ -25,19 +38,25 @@ export const default_pay_period_overview: PayPeriodOverview = {
email: '',
employee_name: '',
regular_hours: -1,
other_hours: {
evening_hours: -1,
emergency_hours: -1,
overtime_hours: -1,
sick_hours: -1,
holiday_hours: -1,
vacation_hours: -1,
},
total_hours: -1,
expenses: -1,
mileage: -1,
is_approved: false
}
export const pay_period_overview_columns = [
export const pay_period_overview_columns: QTableColumn[] = [
{
name: 'employee_name',
label: 'timesheet_approvals.table.full_name',
align: 'left',
field: 'employee_name',
sortable: true
},
@ -48,27 +67,45 @@ export const pay_period_overview_columns = [
sortable: true,
},
{
name: 'regular_hours',
name: 'REGULAR',
label: 'shared.shift_type.regular',
field: 'regular_hours',
sortable: true,
},
{
name: 'evening_hours',
name: 'EVENING',
label: 'shared.shift_type.evening',
field: 'evening_hours',
field: row => row.other_hours.evening_hours,
sortable: true,
},
{
name: 'emergency_hours',
name: 'EMERGENCY',
label: 'shared.shift_type.emergency',
field: 'emergency_hours',
field: row => row.other_hours.emergency_hours,
sortable: true,
},
{
name: 'overtime_hours',
name: 'SICK',
label: 'shared.shift_type.sick',
field: row => row.other_hours.sick_hours,
sortable: true,
},
{
name: 'HOLIDAY',
label: 'shared.shift_type.holiday',
field: row => row.other_hours.holiday_hours,
sortable: true,
},
{
name: 'VACATION',
label: 'shared.shift_type.vacation',
field: row => row.other_hours.vacation_hours,
sortable: true,
},
{
name: 'OVERTIME',
label: 'shared.shift_type.overtime',
field: 'overtime_hours',
field: row => row.other_hours.overtime_hours,
sortable: true,
},
{
@ -89,4 +126,4 @@ export const pay_period_overview_columns = [
field: 'is_approved',
sortable: true,
}
];
]

View File

@ -8,9 +8,11 @@
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';
import { useTimesheetStore } from 'src/stores/timesheet-store';
const { t } = useI18n();
const timesheet_store = useTimesheetStore();
const expenses_store = useExpensesStore();
const expenses_api = useExpensesApi();
const files = defineModel<File[] | null>('files');
@ -35,7 +37,7 @@
<template>
<q-form
flat
v-if="!expenses_store.pay_period_expenses.is_approved"
v-if="!timesheet_store.pay_period_details.weeks[0]?.is_approved"
@submit.prevent="requestExpenseCreationOrUpdate"
>
<div class="text-subtitle2 q-py-sm">
@ -87,7 +89,7 @@
map-options
:label="$t('timesheet.expense.type')"
:rules="[rules.typeRequired]"
:option-label="label => $t(label)"
:option-label="label => $t(`timesheet.expense.types.${label}`)"
/>
<!-- amount input -->

View File

@ -8,26 +8,34 @@
</script>
<template>
<q-item class="row justify-between">
<q-item class="row justify-between items-center q-pa-none">
<q-item-label
header
class="text-h6 col-auto"
class="text-h6 col q-pa-none"
>
{{ $t('timesheet.expense.title') }}
</q-item-label>
<q-item-section class="items-center col-auto">
<q-item-section
no-wrap
class="col-auto items-center"
>
<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)"
outline
class="q-py-xs q-px-md"
color="primary"
:label="$t('timesheet.expense.total_amount') + ': $' + expense_store.pay_period_expenses.total_expense.toFixed(2)"
/>
</q-item-section>
<q-separator spaced />
<q-item-section
no-wrap
class="col-auto items-center"
>
<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)"
outline
class="q-py-xs q-px-md"
color="primary"
:label="$t('timesheet.expense.total_mileage') + ': ' + expense_store.pay_period_expenses.total_mileage.toFixed(1) + ' km'"
/>
</q-item-section>
</q-item>

View File

@ -0,0 +1,201 @@
<script
setup
lang="ts"
>
import { computed, inject, ref } from 'vue';
import { unwrapAndClone } from 'src/utils/unwrap-and-clone';
import { useExpensesApi } from 'src/modules/timesheets/composables/api/use-expense-api';
import { useExpensesStore } from 'src/stores/expense-store';
import { useTimesheetStore } from 'src/stores/timesheet-store';
import { getExpenseTypeIcon } from 'src/modules/timesheets/utils/expense.util';
import { default_expense, type Expense } from 'src/modules/timesheets/models/expense.models';
import { useAuthStore } from 'src/stores/auth-store';
import { CAN_APPROVE_PAY_PERIODS } from 'src/modules/shared/models/user.models';
const { expense, horizontal = false } = defineProps<{
expense: Expense;
index: number;
horizontal?: boolean;
}>();
const timesheet_store = useTimesheetStore();
const expenses_store = useExpensesStore();
const auth_store = useAuthStore();
const expenses_api = useExpensesApi();
const is_approved = defineModel<boolean>({ required: true });
const is_selected = ref(false);
const refresh_key = ref(1);
const is_authorized_to_approve = computed(() => CAN_APPROVE_PAY_PERIODS.includes(auth_store.user.role))
const expenseItemStyle = computed(() => is_approved.value ? 'border: solid 2px var(--q-primary);' : 'border: solid 2px grey;');
const highlightClass = computed(() => (expenses_store.mode === 'update' && is_selected) ? 'bg-accent' : '');
const approvedClass = computed(() => horizontal ? ' q-mx-xs q-pa-xs cursor-pointer' : '')
const employeeEmail = inject<string>('employeeEmail') ?? '';
const setExpenseToModify = () => {
expenses_store.mode = 'update';
expenses_store.current_expense = expense;
expenses_store.initial_expense = unwrapAndClone(expense);
};
const requestExpenseDeletion = async () => {
expenses_store.mode = 'delete';
expenses_store.initial_expense = expense;
expenses_store.current_expense = default_expense;
await expenses_api.deleteExpenseByEmployeeEmail(employeeEmail, expenses_store.initial_expense.date);
}
function onExpenseClicked() {
if (is_authorized_to_approve.value) {
is_approved.value = !is_approved.value;
refresh_key.value += 1;
}
}
</script>
<template>
<transition
enter-active-class="animated pulse"
mode="out-in"
>
<q-item
:key="refresh_key"
:clickable="horizontal"
class="row q-mx-xs shadow-2"
:style="expenseItemStyle + highlightClass + approvedClass"
@click="onExpenseClicked"
>
<q-badge
v-if="expense.is_approved"
class="absolute z-top rounded-20 bg-white q-pa-none"
style="transform: translate(-15px, -15px);"
>
<q-icon
name="verified"
color="primary"
size="md"
/>
</q-badge>
<!-- avatar type icon section -->
<q-item-section avatar>
<q-icon
:name="getExpenseTypeIcon(expense.type)"
:color="expense.is_approved ? 'primary' : ($q.dark.isActive ? 'blue-grey-2' : 'grey-8')"
size="lg"
>
<q-badge
v-if="expense.type === 'ON_CALL'"
floating
class="q-pa-none rounded-50 bg-white z-top"
>
<q-icon
name="shield"
size="xs"
:color="expense.is_approved ? 'primary' : ($q.dark.isActive ? 'blue-grey-2' : 'grey-8')"
/>
</q-badge>
</q-icon>
</q-item-section>
<!-- amount or mileage section -->
<q-item-section class="col-auto">
<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="1"
>
<!-- {{ $d(new Date(expense.date), { year: 'numeric', month: 'short', day: 'numeric', weekday: 'short' }) }} -->
{{ expense.date }}
</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
v-if="!horizontal"
top
>
<q-item-label lines="1">
{{ $t('timesheet.expense.employee_comment') }}
</q-item-label>
<q-item-label
caption
lines="1"
>
{{ expense.comment }}
</q-item-label>
</q-item-section>
<!-- supervisor comment section -->
<q-item-section
v-if="expense.supervisor_comment && !horizontal"
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="(!timesheet_store.pay_period_details.weeks[0]?.is_approved && !expense.is_approved) || horizontal"
side
class="q-pa-none"
>
<q-btn
push
dense
size="xs"
color="primary"
icon="edit"
class="q-mb-xs z-top"
@click.stop="setExpenseToModify"
/>
<q-btn
push
dense
size="xs"
color="negative"
icon="close"
class="z-top"
@click.stop="requestExpenseDeletion"
/>
</q-item-section>
</q-item>
</transition>
</template>

View File

@ -2,35 +2,14 @@
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';
import ExpenseCrudDialogListItem from 'src/modules/timesheets/components/expense-crud-dialog-list-item.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);
}
const { horizontal = false } = defineProps<{
horizontal?: boolean;
}>();
</script>
<template>
@ -38,6 +17,7 @@
<q-list
padding
class="rounded-borders"
:class="horizontal ? 'row justify-center' : ''"
>
<q-item-label
v-if="expenses_store.pay_period_expenses.expenses.length === 0"
@ -45,107 +25,15 @@
>
{{ $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"
<ExpenseCrudDialogListItem
v-for="(expense, index) in expenses_store.pay_period_expenses.expenses"
: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"
v-model="expense.is_approved"
:index="index"
:expense="expense"
:horizontal="horizontal"
/>
</q-item-section>
<!-- amount or mileage section -->
<q-item-section top>
<q-item-label v-if="String(expense.type).trim().toUpperCase() === 'MILEAGE'">
<template v-if="typeof expense.mileage === 'number'">
{{ expense.mileage?.toFixed(1) }} km
</template>
<template v-else>
{{ expense.amount.toFixed(2) }} $
</template>
</q-item-label>
<q-item-label v-else>
{{ expense.amount.toFixed(2) }} $
</q-item-label>
<!-- date label -->
<q-item-label
caption
lines="2"
>
{{ $d(new Date(expense.date), { year: 'numeric', month: 'short', day: 'numeric', weekday: 'short' })
}}
</q-item-label>
</q-item-section>
<!-- attachment file icon -->
<q-item-section side>
<q-btn
push
dense
size="md"
color="primary"
class="q-mx-lg"
icon="attach_file"
/>
</q-item-section>
<!-- comment section -->
<q-item-section top>
<q-item-label lines="1">
{{ $t('timesheet.expense.employee_comment') }}
</q-item-label>
<q-item-label
caption
lines="2"
>
{{ expense.comment }}
</q-item-label>
</q-item-section>
<!-- supervisor comment section -->
<q-item-section top>
<q-item-label lines="1">
{{ $t('timesheet.expense.supervisor_comment') }}
</q-item-label>
<q-item-label
v-if="expense.supervisor_comment"
caption
lines="2"
>
{{ expense.supervisor_comment }}
</q-item-label>
</q-item-section>
<q-item-section
v-if="!expenses_store.pay_period_expenses.is_approved && !expense.is_approved"
side
>
<q-btn
push
dense
size="xs"
color="primary"
icon="edit"
@click="setExpenseToModify(expense)"
/>
<q-btn
push
dense
size="xs"
color="negative"
icon="close"
@click="requestExpenseDeletion(expense)"
/>
</q-item-section>
</q-item>
</q-list>
</template>

View File

@ -0,0 +1,79 @@
<script setup lang="ts">
import { useShiftStore } from 'src/stores/shift-store';
import { SHIFT_TYPES } from 'src/modules/timesheets/models/shift.models';
const shift_store = useShiftStore();
defineEmits<{
'onCommentBlur': [void];
}>();
</script>
<template>
<div>
<div class="col-xs-6 col-sm-4 col-md-3 row q-mx-xs q-my-none">
<q-select
v-model="shift_store.current_shift.type"
options-dense
:options="SHIFT_TYPES"
:label="$t('timesheet.shift.types.label')"
class="col q-pa-none"
color="primary"
outlined
dense
square
hide-dropdown-icon
emit-value
map-options
/>
<div class="col-auto column items-center">
<span
class="text-caption q-pa-none q-ma-none"
style="line-height: 0.7em; font-size: 0.7em;"
>{{ $t('timesheet.shift.types.REMOTE') }}</span>
<q-toggle
v-model="shift_store.current_shift.is_remote"
class="q-pa-none q-ma-none"
/>
</div>
</div>
<div class="col-auto row q-mx-xs">
<q-input
v-model="shift_store.current_shift.start_time"
:label="$t('timesheet.shift.fields.start')"
outlined
dense
square
inputmode="numeric"
mask="##:##"
class="col-auto q-mx-xs"
/>
<q-input
v-model="shift_store.current_shift.end_time"
:label="$t('timesheet.shift.fields.end')"
outlined
dense
square
inputmode="numeric"
mask="##:##"
class="col-auto q-mx-xs"
/>
</div>
<q-input
v-model="shift_store.current_shift.comment"
type="textarea"
autogrow
filled
dense
square
:label="$t('timesheet.shift.fields.header_comment')"
:counter="true"
:maxlength="512"
class="col-auto"
/>
</div>
</template>

View File

@ -5,10 +5,9 @@
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 shift_store = useShiftStore();
const shift_api = useShiftApi();
const { employeeEmail } = defineProps<{
employeeEmail: string;
@ -19,33 +18,38 @@
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)
shift_store.mode === 'delete' ||
(shift_store.current_shift.start_time.trim().length === 5 &&
shift_store.current_shift.end_time.trim().length === 5 &&
shift_store.current_shift.type !== undefined)
);
</script>
<template>
<q-dialog
v-model=" is_open"
v-model="shift_store.is_open"
persistent
full-width
transition-show="fade"
transition-hide="fade"
>
<q-card class="q-pa-md">
<div class="row items-center q-mb-sm">
<q-card
class="q-pa-md rounded-10 shadow-5"
:style="$q.screen.gt.sm ? 'max-width: 60vw !important;' : ''"
>
<q-card-section class="row items-center q-mb-sm q-pa-none">
<q-icon
name="schedule"
size="24px"
class="q-mr-sm"
color="primary"
/>
<div class="text-h6">
{{
mode === 'create'
shift_store.mode === 'create'
? $t('timesheet.shift.actions.add')
: mode === 'update'
: shift_store.mode === 'update'
? $t('timesheet.shift.actions.edit')
: $t('timesheet.shift.actions.delete')
}}
@ -55,68 +59,17 @@
outline
color="primary"
>
{{ date_iso }}
{{ shift_store.date_iso }}
</q-badge>
</div>
</q-card-section>
<q-separator spaced />
<div
v-if="mode !== 'delete'"
class="column q-gutter-md"
v-if="shift_store.mode !== 'delete'"
class="row no-wrap items-start justify-center"
>
<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
@ -157,15 +110,15 @@
flat
color="grey-8"
:label="$t('timesheet.cancel_button')"
@click="close"
@click="shift_store.close"
/>
<q-btn
color="primary"
icon="save_alt"
:label="mode === 'delete' ? $t('timesheet.delete_button') : $t('timesheet.save_button')"
:label="shift_store.mode === 'delete' ? $t('timesheet.delete_button') : $t('timesheet.save_button')"
:loading="isSubmitting"
:disable="!canSubmit"
@click="upsertOrDeleteShiftByEmployeeEmail(employeeEmail)"
@click="shift_api.upsertOrDeleteShiftByEmployeeEmail(employeeEmail)"
/>
</div>
</q-card>

View File

@ -10,7 +10,7 @@
const is_showing_legend = ref(false);
const legend: ShiftLegendItem[] = [
{ type: 'REGULAR', color: 'secondary', label_type: 'timesheet.shift.types.REGULAR', text_color: 'grey-8' },
{ type: 'REGULAR', color: 'secondary', label_type: 'timesheet.shift.types.REGULAR' },
{ 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' },
@ -35,7 +35,7 @@
dense
rounded
color="primary"
class="col-auto q-ma-sm"
class="col-auto q-my-sm"
@click="is_showing_legend = !is_showing_legend"
>
<template #default>
@ -55,7 +55,7 @@
>
<div
v-if="is_showing_legend"
class="q-pa-xs bg-white rounded-5 shadow-2 text-center q-ma-xs"
class="q-py-xs bg-white rounded-5 shadow-2 text-center q-my-xs"
>
<q-badge
v-for="shift_type in shift_type_legend"
@ -63,7 +63,7 @@
: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"
class="q-pa-xs q-mx-xs q-my-none text-uppercase text-weight-bolder justify-center"
style="font-size: 0.8em;"
/>
</div>

View File

@ -1,7 +1,12 @@
<script setup lang="ts">
import { computed } from 'vue';
<script
setup
lang="ts"
>
import { computed, ref } from 'vue';
import { useQuasar } from 'quasar';
import type { Shift } from 'src/modules/timesheets/models/shift.models';
const q = useQuasar();
const { shift, dense = false } = defineProps<{
shift: Shift;
@ -20,7 +25,9 @@
})
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 hour_font_size = computed(() => dense ? 'font-size: 1em;' : 'font-size: 1.5em;')
const is_hovering = ref(false);
const font_color = computed(() => shift.type === 'REGULAR' ? ( q.dark.isActive ? ' text-blue-grey-2' : ' text-grey-8' ) : ' text-white' )
const get_shift_color = (type: string): string => {
switch (type) {
@ -35,14 +42,6 @@
}
};
const get_text_color = (type: string): string => {
switch (type) {
case 'REGULAR': return 'grey-8';
case '': return 'grey-5';
default: return 'white';
}
}
const onClickUpdate = (type: string) => {
if (type !== '') { emit('request-update', shift) };
}
@ -53,17 +52,33 @@
<template>
<q-card-section
horizontal
class="q-pa-none text-uppercase text-center items-center rounded-10"
:class="shift.type"
class="q-py-none q-mx-md text-uppercase text-center items-center rounded-10 cursor-pointer"
style="line-height: 1;"
@click.stop="onClickUpdate(shift.type)"
@mouseenter="is_hovering = true"
@mouseleave="is_hovering = false"
>
<!-- highlight hovering div -->
<transition
enter-active-class="animated zoomIn"
leave-active-class="animated zoomOut"
>
<div
v-if="is_hovering"
class="absolute shift-highlight full-height full-width no-pointer-events"
></div>
</transition>
<div class="col row">
<!-- punch-in timestamp -->
<q-card-section
class="col q-pa-none"
:class="dense ? 'q-px-xs q-mx-xs' : ''"
>
<!-- 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(shift.type) + ' text-' + get_text_color(shift.type)"
:style="'font-size: ' + hour_font_size + '; line-height: 80% !important;'"
:class="'bg-' + get_shift_color(shift.type) + font_color"
:style="hour_font_size + ' line-height: 80% !important;'"
>
{{ shift.start_time }}
</q-item-label>
@ -72,7 +87,7 @@
<!-- arrows pointing to punch-out timestamps -->
<q-card-section
horizontal
class="items-center justify-center q-mx-sm col"
class="col items-center justify-center q-mx-sm"
>
<div
v-for="icon_data, index in [
@ -81,54 +96,53 @@
:key="index"
>
<q-icon
v-if="shift.type"
v-if="shift.type && !dense"
name="double_arrow"
:color="icon_data.color"
size="24px"
:size="dense ? '16px' : '24px'"
:style="icon_data.transform"
/>
<span
v-else
class="text-primary"
> > </span>
</div>
</q-card-section>
<!-- punch-out timestamps -->
<q-card-section class="q-pa-none col">
<q-card-section
class="col q-pa-none"
:class="dense ? 'q-px-xs q-mx-xs' : ''"
>
<q-item-label
class="text-weight-bolder text-white q-pa-xs rounded-5"
:class="'bg-' + get_shift_color(shift.type) + ' text-' + get_text_color(shift.type)"
style="font-size: 1.5em; line-height: 80% !important;"
class="text-weight-bolder q-pa-xs rounded-5"
:class="'bg-' + get_shift_color(shift.type) + font_color"
:style="hour_font_size + ' line-height: 80% !important;'"
>
{{ shift.end_time }}
</q-item-label>
</q-card-section>
<div class="col-1"></div>
</div>
<!-- comment and expenses buttons -->
<q-card-section class="col q-pa-none text-right">
<q-card-section class="col-auto q-pa-none no-wrap q-mx-xs">
<!-- comment btn -->
<q-icon
v-if="shift.type"
:name="comment_icon"
:color="comment_color"
class="q-pa-none q-mx-xs"
size="sm"
/>
<!-- expenses btn -->
<q-btn
v-if="shift.type"
flat
dense
color='grey-8'
icon="attach_money"
class="q-pa-none q-mx-xs"
class="q-pa-none q-mr-xs"
:size="dense ? 'xs' : 'sm'"
/>
<!-- delete btn -->
<q-btn
v-if="shift.type"
push
dense
size="sm"
color="red-6"
icon="close"
class="q-ml-xs"
:size="dense ? 'xs' : 'sm'"
color="negative"
icon="clear"
@click.stop="onClickDelete"
/>
</q-card-section>

View File

@ -16,7 +16,9 @@ import { computed } from 'vue';
dense?: boolean;
}>();
const font_size = computed(() => dense ? '1.5em' : '2.5em')
const date_font_size = computed(() => dense ? '1.5em' : '2.5em');
const weekday_font_size = computed(() => dense ? '0.55em;' : '0.7em;');
const date_box_size = computed(() => dense ? 'width: 50px;' : 'width: 75px;');
const get_date_from_short = (short_date: string): Date => {
return new Date(timesheet_store.pay_period.pay_year.toString() + '/' + short_date);
@ -39,30 +41,31 @@ import { computed } from 'vue';
<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"
class="column col q-mx-xs"
>
<q-card
v-for="day, day_index in week.shifts"
:key="day_index + index"
class="row items-center rounded-10 q-mb-xs"
class="row col items-center rounded-10 q-my-xs q-pa-xs"
>
<!-- Dates column -->
<q-card-section class="col-auto q-pa-xs text-white q-mr-md">
<q-card-section class="col-auto q-pa-none text-white">
<div
class="bg-primary rounded-10 q-pa-xs text-center"
:style="'width: ' + dense? '60px' : '75px;'"
:style="date_box_size"
>
<q-item-label
style="font-size: 0.7em;"
v-if="!dense"
:style="'font-size: ' + weekday_font_size"
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;'"
:style="'font-size: ' + date_font_size + '; line-height: 90% !important;'"
>{{ day.short_date.split('/')[1] }}</q-item-label>
<q-item-label
style="font-size: 0.7em;"
:style="'font-size: ' + weekday_font_size"
class="text-uppercase"
>{{ $d(getDate(day.short_date), { month: $q.screen.lt.md ? 'short' : 'long' }) }}</q-item-label>
</div>
@ -70,13 +73,15 @@ import { computed } from 'vue';
<!-- List of shifts column -->
<q-card-section class="col q-pa-none">
<ShiftListHeader v-if="day.shifts.length > 0"/>
<ShiftListHeader v-if="day.shifts.length > 0 && !dense"/>
<div
v-if="day.shifts.length > 0"
class="q-gutter-xs"
>
<ShiftListRow
v-for="shift, shift_index in shifts_or_placeholder(day.shifts)"
:key="shift_index"
:dense="dense"
:shift="shift"
@request-update="value => openUpdate(to_iso_date(day.short_date), value)"
@request-delete="value => openDelete(to_iso_date(day.short_date), value)"
@ -84,12 +89,13 @@ import { computed } from 'vue';
</div>
</q-card-section>
<!-- add shift btn column -->
<q-card-section class="q-pr-xs col-auto">
<q-card-section class="col-auto q-pa-none">
<q-btn
push
:dense="dense"
color="primary"
icon="more_time"
class="q-pa-sm"
:class="dense ? '' : 'q-pa-sm q-mr-sm'"
@click="openCreate(to_iso_date(day.short_date))"
/>
</q-card-section>

View File

@ -20,7 +20,7 @@
}>();
const { is_loading } = useTimesheetStore();
const { getPayPeriodDetailsByDate, getPreviousPayPeriodDetails, getNextPayPeriodDetails } = useTimesheetApi();
const timesheet_api = useTimesheetApi();
provide('employeeEmail', employeeEmail);
</script>
@ -28,7 +28,7 @@
<template>
<q-card
flat
class="q-mt-md bg-secondary full-width"
class="transparent full-width"
>
<q-inner-loading
:showing="is_loading"
@ -37,19 +37,20 @@
<q-card-section
:horizontal="$q.screen.gt.sm"
class="q-px-lg items-center"
class="q-px-md items-center"
:class="$q.screen.lt.md ? 'column' : ''"
>
<!-- navigation btn -->
<PayPeriodNavigator
@date-selected="getPayPeriodDetailsByDate"
@pressed-previous-button="getPreviousPayPeriodDetails"
@pressed-next-button="getNextPayPeriodDetails"
v-if="!dense"
@date-selected="timesheet_api.getPayPeriodDetailsByDate(employeeEmail)"
@pressed-previous-button="timesheet_api.getPreviousPayPeriodDetails(employeeEmail)"
@pressed-next-button="timesheet_api.getNextPayPeriodDetails(employeeEmail)"
/>
<!-- mobile expenses button -->
<q-btn
v-if="$q.screen.lt.md"
v-if="$q.screen.lt.md && !dense"
push
rounded
color="primary"
@ -60,13 +61,13 @@
/>
<!-- shift's colored legend -->
<ShiftListLegend :is-loading="false" />
<ShiftListLegend v-if="!dense" :is-loading="false" />
<q-space />
<!-- desktop expenses button -->
<q-btn
v-if="$q.screen.gt.sm"
v-if="$q.screen.gt.sm && !dense"
push
rounded
color="primary"
@ -77,7 +78,7 @@
</q-card-section>
<q-card-section :horizontal="$q.screen.gt.sm">
<q-card-section :horizontal="$q.screen.gt.sm" class="bg-secondary q-pa-sm rounded-10">
<ShiftList :dense="dense"/>
</q-card-section>
</q-card>

View File

@ -1,6 +1,6 @@
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 { 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";
@ -22,49 +22,49 @@ export const useShiftApi = () => {
};
};
const parseHHMM = (s: string): [number, number] => {
const m = /^(\d{2}):(\d{2})$/.exec(s);
// const parseHHMM = (s: string): [number, number] => {
// const m = TIME_FORMAT_PATTERN.exec(s);
if (!m) {
throw new GenericApiError({ 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]);
// const h = Number(m[1]);
// const min = Number(m[2]);
if (Number.isNaN(h) || Number.isNaN(min) || h < 0 || h > 23 || min < 0 || min > 59) {
throw new GenericApiError({ status_code: 400, message: `Invalid time value: ${s}.` })
}
return [h, min];
};
// if (Number.isNaN(h) || Number.isNaN(min) || h < 0 || h > 23 || min < 0 || min > 59) {
// throw new GenericApiError({ status_code: 400, message: `Invalid time value: ${s}.` })
// }
// return [h, min];
// };
const toMinutes = (hhmm: string): number => {
const [h, m] = parseHHMM(hhmm);
return h * 60 + m;
};
// const toMinutes = (hhmm: string): number => {
// const [h, m] = parseHHMM(hhmm);
// return h * 60 + m;
// };
const validateShift = (shift: Shift, label: 'old_shift' | 'new_shift') => {
if (!TIME_FORMAT_PATTERN.test(shift.start_time) || !TIME_FORMAT_PATTERN.test(shift.end_time)) {
throw new GenericApiError({
status_code: 400,
message: `Invalid time format in ${label}. Expected HH:MM`,
context: { [label]: shift }
});
}
// 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, got ${shift.start_time} - ${shift.end_time}`,
// context: { [label]: shift }
// });
// }
if (toMinutes(shift.end_time) <= toMinutes(shift.start_time)) {
throw new GenericApiError({
status_code: 400,
message: `Invalid time range in ${label}. The End time must be after the Start time`,
context: { [label]: shift }
});
}
};
// if (toMinutes(shift.end_time) <= toMinutes(shift.start_time)) {
// throw new GenericApiError({
// status_code: 400,
// message: `Invalid time range in ${label}. The End time must be after the Start time`,
// context: { [label]: shift }
// });
// }
// };
const upsertOrDeleteShiftByEmployeeEmail = async (employee_email: string): Promise<void> => {
const flat_upsert_shift: UpsertShift = {
...(deepEqual(shift_store.initial_shift, default_shift) ? { old_shift: unwrapAndClone(shift_store.initial_shift) } : {}),
...(deepEqual(shift_store.current_shift, default_shift) ? { new_shift: unwrapAndClone(shift_store.current_shift) } : {}),
...(deepEqual(shift_store.initial_shift, default_shift) ? undefined : { old_shift: unwrapAndClone(shift_store.initial_shift) }),
...(deepEqual(shift_store.current_shift, default_shift) ? undefined : { new_shift: unwrapAndClone(shift_store.current_shift) }),
};
const normalized_upsert_shift: UpsertShift = {
@ -72,8 +72,8 @@ export const useShiftApi = () => {
...(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');
// 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);
};

View File

@ -1,2 +1,3 @@
export const TIME_FORMAT_PATTERN = /^\d{2}:\d{2}$/;
export const TIME_FORMAT_PATTERN = /^(\d{2}:\d{2})?$/;
export const DATE_FORMAT_PATTERN = /^\d{4}-\d{2}-\d{2}$/;

View File

@ -1,8 +1,8 @@
export type ExpenseType = 'PER_DIEM' | 'MILEAGE' | 'EXPENSES' | 'PRIME_GARDE';
export type ExpenseType = 'PER_DIEM' | 'MILEAGE' | 'EXPENSES' | 'ON_CALL';
export const EXPENSE_TYPE: ExpenseType[] = ['PER_DIEM', 'MILEAGE', 'EXPENSES', 'PRIME_GARDE',];
export const EXPENSE_TYPE: ExpenseType[] = ['PER_DIEM', 'MILEAGE', 'EXPENSES', 'ON_CALL',];
export const TYPES_WITH_MILEAGE_ONLY: Readonly<ExpenseType[]> = ['MILEAGE'];
export const TYPES_WITH_AMOUNT_ONLY: Readonly<ExpenseType[]> = ['PER_DIEM', 'EXPENSES', 'PRIME_GARDE',];
export const TYPES_WITH_AMOUNT_ONLY: Readonly<ExpenseType[]> = ['PER_DIEM', 'EXPENSES', 'ON_CALL',];
export interface Expense {
date: string;
@ -21,9 +21,9 @@ export type ExpenseTotals = {
};
export interface PayPeriodExpenses {
is_approved: boolean;
expenses: Expense[];
totals?: ExpenseTotals;
total_expense: number;
total_mileage: number;
}
export interface UpsertExpense {
@ -40,6 +40,7 @@ export const default_expense: Expense = {
};
export const default_pay_period_expenses: PayPeriodExpenses = {
is_approved: false,
expenses: [],
total_expense: -1,
total_mileage: -1,
}

View File

@ -41,8 +41,8 @@ export interface UpsertShift {
export const default_shift: Readonly<Shift> = {
date: '',
start_time: '--:--',
end_time: '--:--',
start_time: '',
end_time: '',
type: 'REGULAR',
comment: '',
is_approved: false,

View File

@ -32,12 +32,12 @@ export const timesheetService = {
},
getExpensesByPayPeriodAndEmployeeEmail: async (email: string, year: string, period_number: string): Promise<PayPeriodExpenses> => {
const response = await api.get(`/expenses/${email}/${year}/${period_number}`);
const response = await api.get(`/expenses/list/${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);
upsertOrDeleteShiftsByDateAndEmployeeEmail: async (email: string, payload: UpsertShift): Promise<PayPeriodDetails> => {
const response = await api.put(`/shifts/upsert/${email}`, payload);
return response.data;
},

View File

@ -2,20 +2,20 @@ import type { Expense, ExpenseTotals } from "src/modules/timesheets/models/expen
//------------------ normalization / icons ------------------
export const normExpenseType = (type: unknown): string =>
typeof type === 'string' ? type.trim().toUpperCase() : '';
typeof type === 'string' ? type.trim() : '';
const icon_map: Record<string,string> = {
MILEAGE: 'time_to_leave',
EXPENSES: 'receipt_long',
PER_DIEM: 'hotel',
PRIME_GARDE: 'admin_panel_settings',
ON_CALL: 'phone_android',
};
export const expenseTypeIcon = (type: unknown): string => {
export const getExpenseTypeIcon = (type: unknown): string => {
const t = normExpenseType(type);
return (
icon_map[t.toLowerCase()] ??
icon_map[t.replace('-','_').toLowerCase()] ??
icon_map[t] ??
icon_map[t.replace('-','_')] ??
'help_outline'
);
};

View File

@ -0,0 +1,18 @@
<script
setup
lang="ts"
>
import EmployeeListTable from 'src/modules/employee-list/components/employee-list-table.vue';
import EmployeeListAddModifyDialog from 'src/modules/employee-list/components/employee/employee-list-add-modify-dialog.vue';
import PageHeaderTemplate from 'src/modules/shared/components/page-header-template.vue';
</script>
<template>
<q-page>
<EmployeeListAddModifyDialog />
<PageHeaderTemplate title="employee_list.page_header" />
<EmployeeListTable />
</q-page>
</template>

View File

@ -1,7 +1,7 @@
<script setup lang="ts">
import ProfileEmployee from 'src/modules/profile/pages/employee/profile-employee.vue';
import { useAuthStore } from 'src/stores/auth-store';
import { type EmployeeProfile } from 'src/modules/employee-list/types/employee-profile-interface';
import type { EmployeeProfile } from 'src/modules/employee-list/models/employee-profile.models';
const auth_store = useAuthStore();
const employee_roles = [ 'SUPERVISOR', 'EMPLOYEE', 'ADMIN', 'HR', 'ACCOUNTING' ];

View File

@ -1,14 +0,0 @@
<script setup lang="ts">
import SupervisorCrewTable from '../components/supervisor/supervisor-crew-table.vue';
import EmployeeListAddModifyDialog from '../components/employee/employee-list-add-modify-dialog.vue';
</script>
<template>
<q-page>
<EmployeeListAddModifyDialog />
<div class="text-h4 row justify-center q-py-sm q-mt-lg text-uppercase text-weight-bolder">
{{ $t('employee_list.page_header') }}
</div>
<SupervisorCrewTable />
</q-page>
</template>

View File

@ -18,7 +18,7 @@
};
onMounted( async () => {
await timesheet_approval_api.getPayPeriodOverviewsByDate(date.formatDate( new Date(), 'YYYY-MM-DD'));
await timesheet_approval_api.getPayPeriodOverviewsByDateOrYearAndNumber(date.formatDate( new Date(), 'YYYY-MM-DD'));
});
</script>

View File

@ -23,15 +23,17 @@
<template>
<q-page
padding
class="q-pa-md bg-secondary"
class="column q-pa-md bg-secondary flex-center"
>
<PageHeaderTemplate
:title="$t('timesheet.page_header')"
:title="'timesheet.page_header'"
:start-date="timesheet_store.pay_period.period_start"
:end-date="timesheet_store.pay_period.period_end"
/>
<div :style="$q.screen.gt.sm ? 'width: 70vw': ''">
<TimesheetWrapper :employee-email="user.email" />
</div>
</q-page>
</template>

View File

@ -20,7 +20,7 @@ const routes: RouteRecordRaw[] = [
{
path: 'employees',
name: RouteNames.EMPLOYEE_LIST,
component: () => import('src/pages/supervisor-crew-page.vue'),
component: () => import('src/pages/employee-list-page.vue'),
},
{
path: 'timesheet-temp',

View File

@ -1,22 +1,23 @@
import { computed, ref } from "vue";
import { defineStore } from "pinia";
import { AuthService } from "../modules/auth/services/services-auth";
import type { User } from "src/modules/shared/models/user.models";
import { CAN_APPROVE_PAY_PERIODS, UserRole, type User } from "src/modules/shared/models/user.models";
export type CompanyRole = 'guest' | 'supervisor' | 'accounting' | 'human_resources' | 'employee';
const TestUsers: Record<CompanyRole, User> = {
guest: { firstName: 'Unknown', lastName: 'Unknown', email: 'guest@guest.com', role: 'guest' },
supervisor: { firstName: 'User', lastName: 'Test', email: 'user@targointernet.com', role: 'supervisor' },
accounting: { firstName: 'Robin', lastName: 'Clark', email: 'user5@example.test', role: 'supervisor' },
human_resources: { firstName: 'Robin', lastName: 'Clark', email: 'user5@example.test', role: 'supervisor' },
employee: { firstName: 'Robin', lastName: 'Clark', email: 'user5@example.test', role: 'supervisor' },
const TestUsers: Record<UserRole, User> = {
[UserRole.ADMIN]: { firstName: 'Alex', lastName: 'Clark', email: 'user1@targointernet.com', role: UserRole.ADMIN },
[UserRole.SUPERVISOR]: { firstName: 'User', lastName: 'Test', email: 'user@targointernet.com', role: UserRole.SUPERVISOR },
[UserRole.HR]: { firstName: 'Avery', lastName: 'Davis', email: 'user5@example.test', role: UserRole.HR },
[UserRole.ACCOUNTING]: { firstName: 'Avery', lastName: 'Johnson', email: 'user6@example.test', role: UserRole.ACCOUNTING },
[UserRole.EMPLOYEE]: { firstName: 'Alex', lastName: 'Johnson', email: 'user13@example.test', role: UserRole.EMPLOYEE },
[UserRole.DEALER]: { firstName: 'Dea', lastName: 'Ler', email: 'dealer@example.test', role: UserRole.DEALER },
[UserRole.CUSTOMER]: { firstName: 'Custo', lastName: 'Mer', email: 'customer@example.test', role: UserRole.CUSTOMER },
[UserRole.GUEST]: { firstName: 'Guestie', lastName: 'Guesterson', email: 'guest@guest.com', role: UserRole.GUEST },
}
export const useAuthStore = defineStore('auth', () => {
const user = ref<User>(TestUsers.guest);
const user = ref<User>(TestUsers.GUEST);
const authError = ref("");
const isAuthorizedUser = computed(() => user.value.role !== 'guest');
const isAuthorizedUser = computed(() => CAN_APPROVE_PAY_PERIODS.includes(user.value.role));
const login = () => {
//TODO: manage customer login process
@ -30,15 +31,15 @@ export const useAuthStore = defineStore('auth', () => {
};
const logout = () => {
user.value = TestUsers.guest;
user.value = TestUsers.GUEST;
};
const setUser = (bypassRole: string) => {
const setUser = (bypassRole: UserRole) => {
if (bypassRole in TestUsers) {
user.value = TestUsers[bypassRole as CompanyRole];
user.value = TestUsers[bypassRole];
}
else {
user.value = TestUsers.guest;
user.value = TestUsers.GUEST;
}
};

View File

@ -1,12 +1,11 @@
import { ref } from "vue";
import { defineStore } from "pinia";
import { EmployeeListService } from "src/modules/employee-list/services/services-employee-list";
import { default_employee_profile, type EmployeeProfile } from "src/modules/employee-list/types/employee-profile-interface";
import type { EmployeeListTableItem } from "src/modules/employee-list/types/employee-list-table-interface";
import { EmployeeListService } from "src/modules/employee-list/services/employee-list-service";
import { default_employee_profile, type EmployeeProfile } from "src/modules/employee-list/models/employee-profile.models";
export const useEmployeeStore = defineStore('employee', () => {
const employee = ref<EmployeeProfile>( default_employee_profile );
const employeeList = ref<EmployeeListTableItem[]>([]);
const employeeList = ref<EmployeeProfile[]>([]);
const isShowingEmployeeAddModifyWindow = ref<boolean>(false);
const isLoadingEmployeeProfile = ref(false);
const isLoadingEmployeeList = ref(false);

View File

@ -1,10 +1,9 @@
import { computed, ref } from "vue";
import { ref } from "vue";
import { defineStore } from "pinia";
import { useTimesheetStore } from "src/stores/timesheet-store";
import { default_expense, default_pay_period_expenses, type UpsertExpense, type Expense, type PayPeriodExpenses } from "src/modules/timesheets/models/expense.models";
import { timesheetService } from "src/modules/timesheets/services/timesheet-service";
import { ExpensesApiError, type GenericApiError } from "src/modules/timesheets/models/expense.validation";
import { computeExpenseTotals } from "src/modules/timesheets/utils/expense.util";
import type { UpsertAction } from "src/modules/timesheets/models/shift.models";
@ -15,7 +14,6 @@ export const useExpensesStore = defineStore('expenses', () => {
const is_loading = ref(false);
const mode = ref<UpsertAction>('create');
const pay_period_expenses = ref<PayPeriodExpenses>(default_pay_period_expenses);
const pay_period_expenses_totals = computed(() => computeExpenseTotals(pay_period_expenses.value.expenses))
const current_expense = ref<Expense>(default_expense);
const initial_expense = ref<Expense>(default_expense);
const error = ref<string | null>(null);
@ -97,7 +95,6 @@ export const useExpensesStore = defineStore('expenses', () => {
is_loading,
mode,
pay_period_expenses,
pay_period_expenses_totals,
current_expense,
initial_expense,
error,

View File

@ -44,11 +44,11 @@ export const useShiftStore = defineStore('shift', () => {
const upsertOrDeleteShiftByEmployeeEmail = async (employee_email: string, upsert_shift: UpsertShift) => {
const encoded_email = encodeURIComponent(employee_email);
const encoded_date = encodeURIComponent(current_shift.value.date);
try {
const result = await timesheetService.upsertOrDeleteShiftsByDateAndEmployeeEmail(encoded_email, [ upsert_shift, ], encoded_date);
const result = await timesheetService.upsertOrDeleteShiftsByDateAndEmployeeEmail(encoded_email, upsert_shift);
timesheet_store.pay_period_details = result;
close();
} catch (err) {
console.log('error doing thing: ', err)
// const status_code: number = err?.response?.status ?? 500;