BREAKING(login): implement full auth process using Authentik, remove files containing deprecated code

This commit is contained in:
Nicolas Drolet 2025-10-15 17:06:51 -04:00
parent 702a977fce
commit c1c0faeaf1
51 changed files with 446 additions and 1179 deletions

View File

@ -2,10 +2,10 @@ import { defineBoot } from '#q-app/wrappers';
import axios, { type AxiosInstance } from 'axios';
declare module 'vue' {
interface ComponentCustomProperties {
$axios: AxiosInstance;
$api: AxiosInstance;
}
interface ComponentCustomProperties {
$axios: AxiosInstance;
$api: AxiosInstance;
}
}
// Be careful when using SSR for cross-request state pollution
@ -14,18 +14,21 @@ declare module 'vue' {
// good idea to move this instance creation inside of the
// "export default () => {}" function below (which runs individually
// for each client)
const api = axios.create({ baseURL: import.meta.env.VITE_TARGO_BACKEND_AUTH_URL });
const api = axios.create({
baseURL: import.meta.env.VITE_TARGO_BACKEND_AUTH_URL,
withCredentials: true
});
export default defineBoot(({ app }) => {
// for use inside Vue files (Options API) through this.$axios and this.$api
// for use inside Vue files (Options API) through this.$axios and this.$api
app.config.globalProperties.$axios = axios;
// ^ ^ ^ this will allow you to use this.$axios (for Vue Options API form)
// so you won't necessarily have to import axios in each vue file
app.config.globalProperties.$axios = axios;
// ^ ^ ^ this will allow you to use this.$axios (for Vue Options API form)
// so you won't necessarily have to import axios in each vue file
app.config.globalProperties.$api = api;
// ^ ^ ^ this will allow you to use this.$api (for Vue Options API form)
// so you can easily perform requests against your app's API
app.config.globalProperties.$api = api;
// ^ ^ ^ this will allow you to use this.$api (for Vue Options API form)
// so you can easily perform requests against your app's API
});
export { api };

View File

@ -25,6 +25,10 @@ export default {
tooltip: {
coming_soon: "coming soon!",
},
error: {
login_failed: "Failed to login",
popups_blocked: "Popups are blocked on this device",
},
},
nav_bar: {

View File

@ -25,6 +25,10 @@ export default {
tooltip: {
coming_soon: "à venir!",
},
error: {
login_failed: "Échec à la connexion",
popups_blocked: "Les fenêtres contextuelles sont bloqués sur cet appareil",
},
},
nav_bar: {

View File

@ -1,26 +1,33 @@
<script setup lang="ts">
import { useAuthStore } from 'src/stores/auth-store';
<script
setup
lang="ts"
>
import { ref } from 'vue';
const authStore = useAuthStore();
const currentUser = authStore.user;
// Will need to implement this eventually, just testing the look for now
const notifAmount = ref(7);
const notification_count = ref(7);
</script>
<template>
<q-item clickable v-ripple dark class="q-pa-none">
<q-item-section :side="$q.screen.gt.sm">
<q-avatar rounded >
<q-img src="src/assets/targo-default-avatar.png" />
<q-badge floating color="negative" v-if="notifAmount > 0" >{{ notifAmount }}</q-badge>
</q-avatar>
</q-item-section>
<q-item
clickable
v-ripple
dark
class="q-pa-none q-mt-sm"
>
<q-icon
:name="notification_count > 0 ? 'notifications_active' : 'notifications_off'"
size="lg"
color="white"
/>
<q-badge
v-if="notification_count > 0"
floating
color="negative"
class="text-weight-bolder absolute"
>
{{ notification_count }}
</q-badge>
<q-item-section v-if="$q.screen.gt.sm">
<q-item-label>{{ currentUser.firstName }} {{ currentUser.lastName }}</q-item-label>
<q-item-label caption>{{ notifAmount }} new messages</q-item-label>
</q-item-section>
</q-item>
</template>

View File

@ -1,24 +1,27 @@
<script setup lang="ts">
<script
setup
lang="ts"
>
import { useRouter } from 'vue-router';
import { useAuthStore } from 'src/stores/auth-store';
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';
import { CAN_APPROVE_PAY_PERIODS } from 'src/modules/shared/models/user.models';
const authStore = useAuthStore();
const uiStore = useUiStore();
const auth_store = useAuthStore();
const ui_store = useUiStore();
const router = useRouter();
const miniState = ref(true);
const is_mini = ref(true);
const goToPageName = (pageName: string) => {
router.push({ name: pageName }).catch(err => {
const goToPageName = (page_name: string) => {
router.push({ name: page_name }).catch(err => {
console.error('Error with Vue Router: ', err);
});
};
const handleLogout = () => {
authStore.logout();
auth_store.logout();
router.push({ name: 'login' }).catch(err => {
console.log('could not log you out: ', err);
@ -27,22 +30,30 @@ import { CAN_APPROVE_PAY_PERIODS } from 'src/modules/shared/models/user.models';
</script>
<template>
<q-drawer
v-model="uiStore.isRightDrawerOpen"
overlay
elevated
side="left"
:mini="miniState"
@mouseenter="miniState = false"
@mouseleave="miniState = true"
class="bg-dark"
<q-drawer
v-model="ui_store.isRightDrawerOpen"
overlay
elevated
side="left"
:mini="is_mini"
@mouseenter="is_mini = false"
@mouseleave="is_mini = true"
class="bg-dark"
>
<q-scroll-area class="fit">
<q-list>
<!-- Home -->
<q-item v-ripple clickable side @click="goToPageName(RouteNames.DASHBOARD)">
<q-item
v-ripple
clickable
side
@click="goToPageName(RouteNames.DASHBOARD)"
>
<q-item-section avatar>
<q-icon name="home" color="primary" />
<q-icon
name="home"
color="primary"
/>
</q-item-section>
<q-item-section>
<q-item-label class="text-uppercase text-weight-bold">{{ $t('nav_bar.home') }}</q-item-label>
@ -50,42 +61,77 @@ import { CAN_APPROVE_PAY_PERIODS } from 'src/modules/shared/models/user.models';
</q-item>
<!-- Timesheet Validation -- Supervisor and Accounting only -->
<q-item v-ripple clickable side @click="goToPageName(RouteNames.TIMESHEET_APPROVALS)"
v-if="CAN_APPROVE_PAY_PERIODS.includes(authStore.user.role)">
<q-item
v-ripple
clickable
side
@click="goToPageName(RouteNames.TIMESHEET_APPROVALS)"
v-if="CAN_APPROVE_PAY_PERIODS.includes(auth_store.user?.role ?? 'GUEST')"
>
<q-item-section avatar>
<q-icon name="event_available" color="primary" />
<q-icon
name="event_available"
color="primary"
/>
</q-item-section>
<q-item-section>
<q-item-label class="text-uppercase text-weight-bold">{{ $t('nav_bar.timesheet_approvals') }}</q-item-label>
<q-item-label class="text-uppercase text-weight-bold">{{ $t('nav_bar.timesheet_approvals')
}}</q-item-label>
</q-item-section>
</q-item>
<!-- Employee List -- Supervisor, Accounting and HR only -->
<q-item v-ripple clickable side @click="goToPageName(RouteNames.EMPLOYEE_LIST)"
v-if="CAN_APPROVE_PAY_PERIODS.includes(authStore.user.role)">
<q-item
v-ripple
clickable
side
@click="goToPageName(RouteNames.EMPLOYEE_LIST)"
v-if="CAN_APPROVE_PAY_PERIODS.includes(auth_store.user?.role ?? 'GUEST')"
>
<q-item-section avatar>
<q-icon name="view_list" color="primary" />
<q-icon
name="view_list"
color="primary"
/>
</q-item-section>
<q-item-section>
<q-item-label class="text-uppercase text-weight-bold">{{ $t('nav_bar.employee_list') }}</q-item-label>
<q-item-label class="text-uppercase text-weight-bold">{{ $t('nav_bar.employee_list')
}}</q-item-label>
</q-item-section>
</q-item>
<!-- Employee Timesheet temp -- Employee, Supervisor, Accounting only -->
<q-item v-ripple clickable side @click="goToPageName(RouteNames.TIMESHEET_TEMP)"
v-if="CAN_APPROVE_PAY_PERIODS.includes(authStore.user.role)">
<q-item
v-ripple
clickable
side
@click="goToPageName(RouteNames.TIMESHEET_TEMP)"
v-if="CAN_APPROVE_PAY_PERIODS.includes(auth_store.user?.role ?? 'GUEST')"
>
<q-item-section avatar>
<q-icon name="punch_clock" color="primary" />
<q-icon
name="punch_clock"
color="primary"
/>
</q-item-section>
<q-item-section>
<q-item-label class="text-uppercase text-weight-bold">{{ $t('nav_bar.timesheet') }}</q-item-label>
<q-item-label class="text-uppercase text-weight-bold">{{ $t('nav_bar.timesheet')
}}</q-item-label>
</q-item-section>
</q-item>
<!-- Profile -->
<q-item v-ripple clickable side @click="goToPageName(RouteNames.PROFILE)">
<q-item
v-ripple
clickable
side
@click="goToPageName(RouteNames.PROFILE)"
>
<q-item-section avatar>
<q-icon name="account_box" color="primary" />
<q-icon
name="account_box"
color="primary"
/>
</q-item-section>
<q-item-section>
<q-item-label class="text-uppercase text-weight-bold">{{ $t('nav_bar.profile') }}</q-item-label>
@ -93,9 +139,16 @@ import { CAN_APPROVE_PAY_PERIODS } from 'src/modules/shared/models/user.models';
</q-item>
<!-- Help -->
<q-item v-ripple clickable @click="goToPageName('help')">
<q-item
v-ripple
clickable
@click="goToPageName('help')"
>
<q-item-section avatar>
<q-icon name="contact_support" color="primary" />
<q-icon
name="contact_support"
color="primary"
/>
</q-item-section>
<q-item-section>
<q-item-label class="text-uppercase text-weight-bold">{{ $t('nav_bar.help') }}</q-item-label>
@ -104,9 +157,17 @@ import { CAN_APPROVE_PAY_PERIODS } from 'src/modules/shared/models/user.models';
</q-list>
<!-- Logout -->
<q-item v-ripple clickable @click="handleLogout" class="absolute-bottom">
<q-item
v-ripple
clickable
@click="handleLogout"
class="absolute-bottom"
>
<q-item-section avatar>
<q-icon name="exit_to_app" color="primary" />
<q-icon
name="exit_to_app"
color="primary"
/>
</q-item-section>
<q-item-section>
<q-item-label class="text-uppercase text-weight-bold">{{ $t('nav_bar.logout') }}</q-item-label>

View File

@ -1,34 +0,0 @@
<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: UserRole) => {
auth_api.setUser(bypassRole);
router.push({ name: 'dashboard' }).catch( err => {
console.error('Router navigation failed: ', err);
});
};
</script>
<template>
<q-card class="absolute-bottom-right q-ma-sm">
<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 UserRole"
:key="index"
push
color="primary"
text-color="white"
:label="role"
class="text-uppercase"
@click="setBypassUser(role)"
/>
</q-card-actions>
</q-card>
</template>

View File

@ -1,5 +1,4 @@
import { useAuthStore } from "../../../stores/auth-store";
import type { UserRole } from "src/modules/shared/models/user.models";
export const useAuthApi = () => {
const authStore = useAuthStore();
@ -16,19 +15,9 @@ export const useAuthApi = () => {
authStore.logout();
};
const isAuthorizedUser = () => {
return authStore.isAuthorizedUser;
};
const setUser = (bypassRole: UserRole) => {
authStore.setUser(bypassRole);
}
return {
login,
oidcLogin,
logout,
isAuthorizedUser,
setUser,
};
};

View File

@ -1,6 +1,5 @@
<script setup lang="ts">
import LoginConnectionPanel from 'src/modules/auth/components/login-connection-panel.vue';
import LoginDevBypass from 'src/modules/auth/components/login-dev-bypass.vue';
</script>
<template>
@ -11,9 +10,6 @@
<transition appear slow enter-active-class="animated zoomIn" leave-active-class="animated zoomOut">
<LoginConnectionPanel />
</transition>
<!-- DEV TOOLS -->
<LoginDevBypass />
</q-page>
</q-page-container>
</q-layout>

View File

@ -7,16 +7,6 @@ export const AuthService = {
//TODO: OIDC customer sign-in, eventually
},
oidcLogin: (): Window | null => {
window.addEventListener('message', (event) => {
if (event.data.type === 'authSuccess') {
//some kind of logic here to set user in store
}
})
return window.open('http://localhost:3000/auth/v1/login', 'authPopup', 'width=600,height=800');
},
logout: () => {
// TODO: logout logic
api.post('/auth/logout')
@ -27,8 +17,8 @@ export const AuthService = {
api.post('/auth/refresh')
},
getProfile: () => {
// TODO: user info fetch logic
api.get('/auth/me')
getProfile: async () => {
const response = await api.get('/auth/me');
return response.data;
},
};

View File

@ -0,0 +1,27 @@
<script
setup
lang="ts"
>
defineProps<{
isLoading: boolean;
}>();
</script>
<template>
<q-card
v-if="isLoading"
flat
class="column flex-center rounded-10"
style="width: 20vw !important; height: 20vh !important;"
>
<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.label.loading') }}
</div>
</q-card>
</template>

View File

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

View File

@ -2,6 +2,8 @@
setup
lang="ts"
>
/* eslint-disable */
import { ref } from 'vue';
import { Bar } from 'vue-chartjs';
import { useI18n } from 'vue-i18n';

View File

@ -30,31 +30,7 @@
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"
size="5em"
:thickness="10"
class="col-auto"
/>
<div class="col-auto text-primary text-h6 text-weight-bold text-center ">
{{ $t('shared.label.loading') }}
</div>
</q-card>
</transition>
<q-card
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;'"
>

View File

@ -2,12 +2,12 @@
setup
lang="ts"
>
import type { PayPeriodOverview } from 'src/modules/timesheet-approval/models/pay-period-overview.models';
import type { TimesheetOverview } from 'src/modules/timesheet-approval/models/timesheet-overview.models';
const modelApproval = defineModel<boolean>();
const { row } = defineProps<{ row: PayPeriodOverview; }>();
const { row } = defineProps<{ row: TimesheetOverview; }>();
const emit = defineEmits<{
'clickDetails': [overview: PayPeriodOverview];
'clickDetails': [overview: TimesheetOverview];
}>();
</script>

View File

@ -9,7 +9,7 @@
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';
import { pay_period_overview_columns, type TimesheetOverview } from 'src/modules/timesheet-approval/models/timesheet-overview.models';
const expenses_store = useExpensesStore();
const timesheet_store = useTimesheetStore();
@ -33,7 +33,7 @@
timesheet_store.pay_period_overviews
)
const onClickedDetails = async (employee_email: string, row: PayPeriodOverview) => {
const onClickedDetails = async (employee_email: string, row: TimesheetOverview) => {
employeeEmail.value = employee_email;
timesheet_store.current_pay_period_overview = row;
emit('clickedDetailsButton', employee_email);
@ -144,7 +144,7 @@
</template>
<!-- Template for individual employee cards -->
<template #item="props: { row: PayPeriodOverview, key: string }">
<template #item="props: { row: TimesheetOverview, key: string }">
<OverviewListItem
v-model="props.row.is_approved"
:row="props.row"

View File

@ -1,7 +1,7 @@
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";
import { NavigatorConstants } from "src/modules/timesheet-approval/models/timesheet-overview.models";
export const useTimesheetApprovalApi = () => {
const timesheet_store = useTimesheetStore();

View File

@ -6,7 +6,7 @@ export enum NavigatorConstants {
PREVIOUS_PERIOD = -1,
}
export interface PayPeriodOverview {
export interface TimesheetOverview {
email: string;
employee_name: string;
regular_hours: number;
@ -31,10 +31,10 @@ export interface PayPeriodOverviewResponse {
period_end: string;
payday: string;
label: string;
employees_overview: PayPeriodOverview[];
employees_overview: TimesheetOverview[];
}
export const default_pay_period_overview: PayPeriodOverview = {
export const default_pay_period_overview: TimesheetOverview = {
email: '',
employee_name: '',
regular_hours: -1,

View File

@ -1,6 +1,6 @@
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";
import type { PayPeriodOverviewResponse } from "src/modules/timesheet-approval/models/timesheet-overview.models";
export const timesheetApprovalService = {
getPayPeriodOverviewsBySupervisorEmail: async (year: number, period_number: number, supervisor_email: string): Promise<PayPeriodOverviewResponse> => {

View File

@ -6,8 +6,8 @@
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';
import { useExpenseRules } from 'src/modules/timesheets/utils/expense.util';
import { useExpensesApi } from 'src/modules/timesheets/composables/use-expense-api';
import { useTimesheetStore } from 'src/stores/timesheet-store';
const { t } = useI18n();
@ -20,7 +20,7 @@
const COMMENT_MAX_LENGTH = 280;
const employee_email = inject<string>('employeeEmail');
const rules = makeExpenseRules(t);
const rules = useExpenseRules(t);
const cancelUpdateMode = () => {
expenses_store.current_expense = default_expense;

View File

@ -4,7 +4,7 @@
>
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 { useExpensesApi } from 'src/modules/timesheets/composables/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';
@ -65,7 +65,7 @@
<q-item
:key="refresh_key"
:clickable="horizontal"
class="row q-mx-xs shadow-2"
class="row col-4 q-ma-xs shadow-2"
:style="expenseItemStyle + highlightClass + approvedClass"
@click="onExpenseClicked"
>
@ -126,6 +126,8 @@
</q-item-label>
</q-item-section>
<q-space v-if="horizontal" />
<!-- attachment file icon -->
<q-item-section side>
<q-btn

View File

@ -17,7 +17,7 @@
<q-list
padding
class="rounded-borders"
:class="horizontal ? 'row justify-center' : ''"
:class="horizontal ? 'row flex-center' : ''"
>
<q-item-label
v-if="expenses_store.pay_period_expenses.expenses.length === 0"

View File

@ -2,10 +2,11 @@
setup
lang="ts"
>
import { useShiftStore } from 'src/stores/shift-store';
import { SHIFT_TYPES } from 'src/modules/timesheets/models/shift.models';
import { type Shift, SHIFT_TYPES } from 'src/modules/timesheets/models/shift.models';
const shift_store = useShiftStore();
defineProps<{
shift: Shift;
}>();
defineEmits<{
'onCommentBlur': [void];
@ -13,10 +14,21 @@
</script>
<template>
<div>
<div class="col-xs-6 col-sm-4 col-md-3 row q-mx-xs q-my-none">
<div class="row full-width justify-center">
<div class="col-sm-6 col-md-3 row q-mx-xs q-my-none">
<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.is_remote"
class="q-pa-none q-ma-none"
/>
</div>
<q-select
v-model="shift_store.current_shift.type"
v-model="shift.type"
options-dense
:options="SHIFT_TYPES"
:label="$t('timesheet.shift.types.label')"
@ -29,22 +41,11 @@
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"
v-model="shift.start_time"
:label="$t('timesheet.shift.fields.start')"
outlined
dense
@ -55,7 +56,7 @@
/>
<q-input
v-model="shift_store.current_shift.end_time"
v-model="shift.end_time"
:label="$t('timesheet.shift.fields.end')"
outlined
dense
@ -67,7 +68,7 @@
</div>
<q-input
v-model="shift_store.current_shift.comment"
v-model="shift.comment"
type="textarea"
autogrow
filled
@ -76,7 +77,7 @@
:label="$t('timesheet.shift.fields.header_comment')"
:counter="true"
:maxlength="512"
class="col-auto"
class="col-grow"
/>
</div>
</template>

View File

@ -1,127 +0,0 @@
<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 ShiftCrudDialogAddUpdateShift from 'src/modules/timesheets/components/shift-crud-dialog-add-update-shift.vue';
const shift_store = useShiftStore();
const shift_api = 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(() =>
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="shift_store.is_open"
persistent
full-width
transition-show="fade"
transition-hide="fade"
>
<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">
{{
shift_store.mode === 'create'
? $t('timesheet.shift.actions.add')
: shift_store.mode === 'update'
? $t('timesheet.shift.actions.edit')
: $t('timesheet.shift.actions.delete')
}}
</div>
<q-space />
<q-badge
outline
color="primary"
>
{{ shift_store.date_iso }}
</q-badge>
</q-card-section>
<q-separator spaced />
<div
v-if="shift_store.mode !== 'delete'"
class="row no-wrap items-start justify-center"
>
<ShiftCrudDialogAddUpdateShift />
</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="shift_store.close"
/>
<q-btn
color="primary"
icon="save_alt"
:label="shift_store.mode === 'delete' ? $t('timesheet.delete_button') : $t('timesheet.save_button')"
:loading="isSubmitting"
:disable="!canSubmit"
@click="shift_api.upsertOrDeleteShiftByEmployeeEmail(employeeEmail)"
/>
</div>
</q-card>
</q-dialog>
</template>

View File

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

View File

@ -5,7 +5,7 @@
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<{
@ -19,15 +19,9 @@
'request-delete': [shift: Shift];
}>();
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 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 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) {
@ -58,16 +52,7 @@
@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 -->
@ -130,10 +115,11 @@
<!-- comment btn -->
<q-icon
v-if="shift.type"
:name="comment_icon"
:color="comment_color"
class="q-pa-none q-mr-xs"
name="comment"
color="primary"
:size="dense ? 'xs' : 'sm'"
class="q-pa-none q-mr-xs"
:class="shift.comment ? '' : 'invisible'"
/>
<!-- delete btn -->

View File

@ -2,16 +2,14 @@
setup
lang="ts"
>
/* eslint-disable */
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';
import { computed } from 'vue';
const timesheet_store = useTimesheetStore();
const { openCreate, openDelete, openUpdate } = useShiftStore();
const { dense = false } = defineProps<{
dense?: boolean;
}>();
@ -72,33 +70,22 @@ import { computed } from 'vue';
</q-card-section>
<!-- List of shifts column -->
<q-card-section class="col q-pa-none">
<ShiftListHeader v-if="day.shifts.length > 0 && !dense"/>
<q-card-section class="col column q-pa-none full-height">
<div
v-if="day.shifts.length > 0"
class="q-gutter-xs"
class="col-grow column justify-center"
>
<ShiftListRow
v-for="shift, shift_index in shifts_or_placeholder(day.shifts)"
:key="shift_index"
class="col"
: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)"
@request-update=""
@request-delete=""
/>
</div>
</q-card-section>
<!-- add shift btn column -->
<q-card-section class="col-auto q-pa-none">
<q-btn
push
:dense="dense"
color="primary"
icon="more_time"
:class="dense ? '' : 'q-pa-sm q-mr-sm'"
@click="openCreate(to_iso_date(day.short_date))"
/>
</q-card-section>
</q-card>
</div>
</template>

View File

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

View File

@ -1,41 +0,0 @@
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";
export const useExpensesApi = () => {
const expenses_store = useExpensesStore();
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);
};
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);
};
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);
};
return {
createExpenseByEmployeeEmail,
updateExpenseByEmployeeEmail,
deleteExpenseByEmployeeEmail,
};
};

View File

@ -1,85 +0,0 @@
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";
export const useShiftApi = () => {
const shift_store = useShiftStore();
const normalizeShiftPayload = (shift: Shift): Shift => {
const comment = shift.comment?.trim() || undefined;
return {
date: shift.date,
start_time: shift.start_time,
end_time: shift.end_time,
type: shift.type,
is_approved: shift.is_approved,
is_remote: shift.is_remote,
comment: comment,
};
};
// 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.` });
// }
// const h = Number(m[1]);
// const min = Number(m[2]);
// if (Number.isNaN(h) || Number.isNaN(min) || h < 0 || h > 23 || min < 0 || min > 59) {
// throw new GenericApiError({ status_code: 400, message: `Invalid time value: ${s}.` })
// }
// return [h, min];
// };
// const toMinutes = (hhmm: string): number => {
// const [h, m] = parseHHMM(hhmm);
// return h * 60 + m;
// };
// const validateShift = (shift: Shift, label: 'old_shift' | 'new_shift') => {
// if (!TIME_FORMAT_PATTERN.test(shift.start_time) || !TIME_FORMAT_PATTERN.test(shift.end_time)) {
// throw new GenericApiError({
// status_code: 400,
// message: `Invalid time format in ${label}. Expected HH:MM, 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 }
// });
// }
// };
const upsertOrDeleteShiftByEmployeeEmail = async (employee_email: string): Promise<void> => {
const flat_upsert_shift: UpsertShift = {
...(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 = {
...(flat_upsert_shift.old_shift ? { old_shift: normalizeShiftPayload(flat_upsert_shift.old_shift) } : {}),
...(flat_upsert_shift.new_shift ? { new_shift: normalizeShiftPayload(flat_upsert_shift.new_shift) } : {}),
};
// if (normalized_upsert_shift.old_shift) validateShift(normalized_upsert_shift.old_shift, 'old_shift');
// if (normalized_upsert_shift.new_shift) validateShift(normalized_upsert_shift.new_shift, 'new_shift');
await shift_store.upsertOrDeleteShiftByEmployeeEmail(employee_email, normalized_upsert_shift);
};
return {
upsertOrDeleteShiftByEmployeeEmail,
};
}

View File

@ -0,0 +1,28 @@
/* eslint-disable */
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.models";
import type { Expense } from "src/modules/timesheets/models/expense.models";
export const useExpensesApi = () => {
const expenses_store = useExpensesStore();
const createExpenseByEmployeeEmail = async (employee_email: string, date: string): Promise<void> => {
// await expenses_store.upsertOrDeleteExpensesByEmployeeEmail(employee_email, date, upsert_expense);
};
const updateExpenseByEmployeeEmail = async (employee_email: string, date: string): Promise<void> => {
// await expenses_store.upsertOrDeleteExpensesByEmployeeEmail(employee_email, date, upsert_expense);
};
const deleteExpenseByEmployeeEmail = async (employee_email: string, date: string): Promise<void> => {
// await expenses_store.upsertOrDeleteExpensesByEmployeeEmail(employee_email, date, upsert_expense);
};
return {
createExpenseByEmployeeEmail,
updateExpenseByEmployeeEmail,
deleteExpenseByEmployeeEmail,
};
};

View File

@ -1,36 +0,0 @@
import { ref, computed } from "vue";
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<Expense>) => {
const DEFAULT_TYPE: ExpenseType = 'EXPENSES';
const draft = ref<Partial<Expense>>({
date: '',
type: DEFAULT_TYPE,
comment: '',
...(initial ?? {}),
});
const reset = () => {
draft.value = {
date: '',
type: DEFAULT_TYPE,
comment: '',
}
}
const setType = (value: ExpenseType) => {
draft.value.type = value;
if(value === 'MILEAGE') {
delete draft.value.amount;
} else {
delete draft.value.mileage;
}
};
const showMileage = computed(()=> (draft.value.type as string) === 'MILEAGE');
const showAmount = computed(()=> !showMileage.value);
return { draft, setType, reset, showMileage, showAmount };
}

View File

@ -1,21 +0,0 @@
import { ref } from "vue";
import type { QForm } from "quasar"
export const useExpenseForm = () => {
const formRef = ref<QForm | null>(null);
const triedSubmit = ref(false);
const validateAnd = async (fn: ()=> void | Promise<void>) => {
triedSubmit.value = true;
const ok = await formRef.value?.validate(true);
if(!ok) return false;
await fn();
return true;
};
return {
formRef,
validateAnd,
};
}

View File

@ -1,59 +0,0 @@
// 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";
// const expenses_store = useExpensesStore();
// export const useExpenseItems = () => {
// let expenses = unwrapAndClone(expenses_store.pay_period_expenses.expenses.map(normalizeExpense));
// 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 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);
// validateExpenseUI(candidate, 'expense_draft');
// expenses = [ ...expenses, candidate];
// };
// const removeAt = (index: number) => {
// if(index < 0 || index >= expenses.length) return;
// expenses = expenses.filter((_,i)=> i !== index);
// };
// const validateAll = () => {
// for (const expense of expenses) {
// validateExpenseUI(expense, 'expense_item');
// }
// };
// const payload = () => expenses.map(normalizeExpense);
// return {
// expenses,
// addFromDraft,
// removeAt,
// validateAll,
// payload,
// };
// };

View File

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

View File

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

View File

@ -5,42 +5,21 @@ export const TYPES_WITH_MILEAGE_ONLY: Readonly<ExpenseType[]> = ['MILEAGE'];
export const TYPES_WITH_AMOUNT_ONLY: Readonly<ExpenseType[]> = ['PER_DIEM', 'EXPENSES', 'ON_CALL',];
export interface Expense {
date: string;
type: ExpenseType;
amount: number;
mileage?: number;
comment: string;
id: number;
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 {
expenses: Expense[];
total_expense: number;
total_mileage: number;
}
export interface UpsertExpense {
old_expense: Expense;
new_expense: Expense;
}
export const default_expense: Expense = {
id: -1,
date: '',
type: 'EXPENSES',
amount: 0,
comment: '',
is_approved: false,
};
export const default_pay_period_expenses: PayPeriodExpenses = {
expenses: [],
total_expense: -1,
total_mileage: -1,
}
};

View File

@ -43,6 +43,7 @@ export class ExpensesApiError extends ApiError {
};
export const expense_validation_schema: Normalizer<Expense> = {
id: v => typeof v === 'number' ? v : -1,
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,

View File

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

View File

@ -1,4 +1,4 @@
export const SHIFT_TYPES = [
export const SHIFT_TYPES: ShiftType[] = [
'REGULAR',
'EVENING',
'EMERGENCY',
@ -10,8 +10,6 @@ export const SHIFT_TYPES = [
export type ShiftType = 'REGULAR' | 'EVENING' | 'EMERGENCY' | 'OVERTIME' | 'HOLIDAY' | 'VACATION' | 'SICK' ;
export type CrudAction = 'create' | 'update' | 'delete';
export type ShiftLegendItem = {
type: ShiftType;
color: string;
@ -20,6 +18,7 @@ export type ShiftLegendItem = {
};
export interface Shift {
id: number;
date: string;
type: ShiftType;
start_time: string;
@ -29,17 +28,8 @@ export interface Shift {
is_remote: boolean;
}
export interface UpsertShiftsResponse {
action: CrudAction;
day: Shift[];
}
export interface UpsertShift {
old_shift?: Shift | undefined;
new_shift?: Shift | undefined;
}
export const default_shift: Readonly<Shift> = {
id: -1,
date: '',
start_time: '',
end_time: '',

View File

@ -0,0 +1,36 @@
import type { Shift } from "./shift.models";
import type { Expense } from "src/modules/timesheets/models/expense.models";
export const TIME_FORMAT_PATTERN = /^(\d{2}:\d{2})?$/;
export const DATE_FORMAT_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
export interface Timesheet {
id: number;
weekly_hours: TotalHours;
weekly_expenses: TotalExpenses;
days: TimesheetDay[];
}
export interface TimesheetDay {
date: string;
daily_hours: TotalHours;
daily_expenses: TotalExpenses;
shifts: Shift[];
expenses: Expense[];
}
export interface TotalHours {
regular: number;
evening: number;
emergency: number;
overtime: number;
vacation: number;
holiday: number;
sick: number;
absent: number;
}
export interface TotalExpenses {
expenses: number;
mileage: number;
}

View File

@ -1,6 +0,0 @@
export type PayPeriodLabel = {
start_date: string;
end_date: string;
};
export type UpsertAction = 'created' | 'updated' | 'deleted';

View File

@ -1,12 +1,14 @@
/* eslint-disable */
import { api } from "src/boot/axios";
import type { CrudAction, 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";
import type { Timesheet } from "src/modules/timesheets/models/timesheet.models";
import type { TimesheetOverview } from "src/modules/timesheet-approval/models/timesheet-overview.models";
import type { Expense } from "src/modules/timesheets/models/expense.models";
import { Shift } from "src/modules/timesheets/models/shift.models";
export const timesheetService = {
getPayPeriodDetailsByEmployeeEmail: async (email: string): Promise<PayPeriodDetails> => {
getPayPeriodDetailsByEmployeeEmail: async (email: string): Promise<Timesheet[]> => {
const response = await api.get(`/timesheets/${encodeURIComponent(email)}`);
return response.data;
},
@ -21,36 +23,33 @@ export const timesheetService = {
return response.data;
},
getPayPeriodOverviewsBySupervisorEmail: async (year: number, period_number: number, supervisor_email: string): Promise<PayPeriodOverview[]> => {
getPayPeriodOverviewsBySupervisorEmail: async (year: number, period_number: number, supervisor_email: string): Promise<TimesheetOverview[]> => {
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> => {
getPayPeriodDetailsByPayPeriodAndEmployeeEmail: async (year: number, period_no: number, email: string): Promise<Timesheet[]> => {
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/list/${email}/${year}/${period_number}`);
// getExpensesByPayPeriodAndEmployeeEmail: async (email: string, year: string, period_number: string): Promise<PayPeriodExpenses> => {
// const response = await api.get(`/expenses/list/${email}/${year}/${period_number}`);
// return response.data;
// },
upsertShiftsByEmployeeEmailAndAction: async (email: string, payload: Shift[]): Promise<Timesheet[]> => {
const response = await api.put(`/shifts/upsert/${email}`, payload);
return response.data;
},
upsertShiftsByEmployeeEmailAndAction: async (email: string, payload: UpsertShift, action: CrudAction): Promise<PayPeriodDetails> => {
const response = await api.put(`/shifts/upsert/${email}?action=${action}`, payload);
return response.data;
},
deleteShiftsByEmployeeEmailAndDate: async (email: string, date: string, payload: UpsertShift) => {
console.log('sent old shift: ', payload);
deleteShiftsByEmployeeEmailAndDate: async (email: string, date: string, payload: Shift[]) => {
const response = await api.delete(`/shifts/delete/${email}/${date}`, { data: payload });
return response;
},
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 });
upsertOrDeleteExpensesByPayPeriodAndEmployeeEmail: async (email: string, date: string, payload: Expense[]): Promise<Timesheet[]> => {
const response = await api.put(`/expenses/upsert/${email}/${date}`, payload);
return response.data;
},
};

View File

@ -1,46 +1,21 @@
import type { Expense, ExpenseTotals } from "src/modules/timesheets/models/expense.models";
import type { ExpenseType } from "src/modules/timesheets/models/expense.models";
//------------------ normalization / icons ------------------
export const normExpenseType = (type: unknown): string =>
typeof type === 'string' ? type.trim() : '';
const icon_map: Record<string,string> = {
MILEAGE: 'time_to_leave',
EXPENSES: 'receipt_long',
PER_DIEM: 'hotel',
ON_CALL: 'phone_android',
export const getExpenseIcon = (type: ExpenseType) => {
switch (type) {
case 'MILEAGE': return 'time_to_leave';
case 'EXPENSES': return 'receipt_long';
case 'PER_DIEM': return 'hotel';
case 'ON_CALL': return 'phone_android';
default: return 'help_outline';
}
};
export const getExpenseTypeIcon = (type: unknown): string => {
const t = normExpenseType(type);
return (
icon_map[t] ??
icon_map[t.replace('-','_')] ??
'help_outline'
);
};
//------------------ totals ------------------
export const computeExpenseTotals = (items: readonly Expense[]): ExpenseTotals =>
items.reduce<ExpenseTotals>(
(acc, e) => ({
amount: acc.amount + (Number(e.amount) || 0),
mileage: acc.mileage + (Number(e.mileage) || 0),
}),
{ amount: 0, mileage: 0 }
);
//------------------ Quasar :rules=[] ------------------
export const makeExpenseRules = (t: (_key: string) => string) => {
export const useExpenseRules = (t: (_key: string) => string) => {
const isPresent = (val: unknown) => val !== undefined && val !== null && val !== '';
const typeRequired = (val: unknown) => (!!val) || t('timesheet.expense.errors.type_required');
const amountRequired = (val: unknown) => (isPresent(val)) || t('timesheet.expense.errors.amount_required_for_type');
const mileageRequired = (val: unknown) => (isPresent(val)) || t('timesheet.expense.errors.mileage_required_for_type');
const commentRequired = (val: unknown) => (typeof val === 'string' ? val.trim().length > 0 : false ) || t('timesheet.expense.errors.comment_required');
const typeRequired = (val: unknown) => (!!val) || t('timesheet.expense.errors.type_required');
const amountRequired = (val: unknown) => (isPresent(val)) || t('timesheet.expense.errors.amount_required_for_type');
const mileageRequired = (val: unknown) => (isPresent(val)) || t('timesheet.expense.errors.mileage_required_for_type');
const commentRequired = (val: unknown) => (typeof val === 'string' ? val.trim().length > 0 : false) || t('timesheet.expense.errors.comment_required');
return {
typeRequired,

View File

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

View File

@ -1,18 +0,0 @@
// 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 buildShiftOptions = (
// keys: readonly string[],
// t:(k: string) => string
// ): ShiftSelectOption[] =>
// keys.map((key) => ({
// value: key as any,
// label: t(`timesheet.shift.types.${key}`),
// }));

View File

@ -1,17 +0,0 @@
import type { PayPeriodLabel } from "src/modules/timesheets/models/ui.models";
export const formatPayPeriodLabel = (
raw_label: string | undefined,
locale: string,
extractDate: (_input: string, _mask: string) => Date,
opts: Intl.DateTimeFormatOptions
): PayPeriodLabel => {
const label = raw_label ?? '';
const dates = label.split('.');
if(dates.length < 2) return { start_date: '—', end_date:'—' };
const fmt = new Intl.DateTimeFormat(locale, opts);
const start = fmt.format(extractDate(dates[0]!, 'YYYY-MM-DD'));
const end = fmt.format(extractDate(dates[1]!, 'YYYY-MM-DD'));
return { start_date: start, end_date: end };
}

View File

@ -5,7 +5,7 @@
import { date } from 'quasar';
import { onMounted } from 'vue';
import { useAuthStore } from 'src/stores/auth-store';
import { useTimesheetApi } from 'src/modules/timesheets/composables/api/use-timesheet-api';
import { useTimesheetApi } from 'src/modules/timesheets/composables/use-timesheet-api';
import { useTimesheetStore } from 'src/stores/timesheet-store';
import PageHeaderTemplate from 'src/modules/shared/components/page-header-template.vue';
import TimesheetWrapper from 'src/modules/timesheets/components/timesheet-wrapper.vue';
@ -31,8 +31,8 @@
:end-date="timesheet_store.pay_period.period_end"
/>
<div :style="$q.screen.gt.sm ? 'width: 70vw': ''">
<TimesheetWrapper :employee-email="user.email" />
<div class="col column flex-center" :style="$q.screen.gt.sm ? 'width: 70vw': ''">
<TimesheetWrapper class="col" :employee-email="user.email" />
</div>
</q-page>

View File

@ -1,48 +1,49 @@
import { computed, ref } from "vue";
import { defineStore } from "pinia";
import { AuthService } from "../modules/auth/services/services-auth";
import { CAN_APPROVE_PAY_PERIODS, UserRole, type User } from "src/modules/shared/models/user.models";
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 },
}
import { CAN_APPROVE_PAY_PERIODS, type User } from "src/modules/shared/models/user.models";
import { useRouter } from "vue-router";
import { Notify } from "quasar";
export const useAuthStore = defineStore('auth', () => {
const user = ref<User>(TestUsers.GUEST);
const user = ref<User>();
const authError = ref("");
const isAuthorizedUser = computed(() => CAN_APPROVE_PAY_PERIODS.includes(user.value.role));
const isAuthorizedUser = computed(() => CAN_APPROVE_PAY_PERIODS.includes(user.value?.role ?? 'GUEST'));
const router = useRouter();
const login = () => {
//TODO: manage customer login process
};
const oidcLogin = () => {
const oidcPopup = AuthService.oidcLogin();
if (!oidcPopup) {
authError.value = "You have popups blocked on this website!";
}
const oidcLogin = async (): Promise<void> => {
window.addEventListener('message', async (event) => {
if (event.data.type === 'authSuccess') {
const new_user = await AuthService.getProfile();
user.value = new_user;
router.push('/');
} else {
Notify.create({
message: "You have popups blocked on this website!",
color: 'negative',
textColor: 'white',
});
}
});
const oidc_popup = window.open('http://localhost:3000/auth/v1/login', 'authPopup', 'width=600,height=800');
if (!oidc_popup)
Notify.create({
message: "You have popups blocked on this website!",
color: 'negative',
textColor: 'white',
});
};
const logout = () => {
user.value = TestUsers.GUEST;
user.value = undefined;
};
const setUser = (bypassRole: UserRole) => {
if (bypassRole in TestUsers) {
user.value = TestUsers[bypassRole];
}
else {
user.value = TestUsers.GUEST;
}
};
return { user, authError, isAuthorizedUser, login, oidcLogin, logout, setUser };
return { user, authError, isAuthorizedUser, login, oidcLogin, logout };
});

View File

@ -1,87 +0,0 @@
import { ref } from "vue";
import { defineStore } from "pinia";
import { unwrapAndClone } from "src/utils/unwrap-and-clone";
import { timesheetService } from "src/modules/timesheets/services/timesheet-service";
import { useTimesheetStore } from "src/stores/timesheet-store";
import { default_shift, type CrudAction, type Shift, type UpsertShift } from "src/modules/timesheets/models/shift.models";
export const useShiftStore = defineStore('shift', () => {
const is_open = ref(false);
const mode = ref<CrudAction>('create');
const date_iso = ref<string>('');
const current_shift = ref<Shift>(default_shift);
const initial_shift = ref<Shift>(default_shift);
const timesheet_store = useTimesheetStore();
const open = (next_mode: CrudAction, date: string, current: Shift, initial: Shift) => {
mode.value = next_mode;
date_iso.value = date;
current_shift.value = current; // new shift
initial_shift.value = initial; // old shift
is_open.value = true;
};
const openCreate = (date: string) => {
open('create', date, default_shift, default_shift);
};
const openUpdate = (date: string, shift: Shift) => {
open('update', date, shift, unwrapAndClone(shift));
};
const openDelete = (date: string, shift: Shift) => {
open('delete', date, default_shift, shift);
}
const close = () => {
is_open.value = false;
mode.value = 'create';
date_iso.value = '';
};
const upsertOrDeleteShiftByEmployeeEmail = async (employee_email: string, upsert_shift: UpsertShift) => {
const encoded_email = encodeURIComponent(employee_email);
try {
if (mode.value === 'delete') {
const result = await timesheetService.deleteShiftsByEmployeeEmailAndDate(encoded_email, upsert_shift.old_shift?.date ?? '', upsert_shift);
console.log('result: ', result);
if (result.status === 200) {
await timesheet_store.getPayPeriodDetailsByEmployeeEmail(employee_email);
}
} else {
const result = await timesheetService.upsertShiftsByEmployeeEmailAndAction(encoded_email, upsert_shift, mode.value);
timesheet_store.pay_period_details = result;
}
close();
} catch (err) {
console.log('error doing thing: ', err);
// const status_code: number = err?.response?.status ?? 500;
// const data = err?.response?.data ?? {};
// throw new GenericApiError({
// status_code,
// error_code: data.error_code,
// message: data.message || data.error || err.message,
// context: data.context,
// });
} finally {
close();
}
}
return {
is_open,
mode,
date_iso,
current_shift,
initial_shift,
openCreate,
openUpdate,
openDelete,
close,
upsertOrDeleteShiftByEmployeeEmail,
};
})

View File

@ -3,16 +3,16 @@ import { computed, ref } from 'vue';
import { withLoading } from 'src/utils/store-helpers';
import { timesheetApprovalService } from 'src/modules/timesheet-approval/services/timesheet-approval-service';
import { timesheetService } from 'src/modules/timesheets/services/timesheet-service';
import { default_pay_period_overview, type PayPeriodOverview } from "src/modules/timesheet-approval/models/pay-period-overview.models";
import { default_pay_period_overview, type TimesheetOverview } from "src/modules/timesheet-approval/models/timesheet-overview.models";
import { default_pay_period, type PayPeriod } from 'src/modules/shared/models/pay-period.models';
import { default_pay_period_details, type PayPeriodDetails } from 'src/modules/timesheets/models/pay-period-details.models';
import { default_pay_period_details, type PayPeriodDetails } from 'src/modules/timesheets/models/timesheet.models';
import type { TimesheetApprovalCSVReportFilters } from 'src/modules/timesheet-approval/models/timesheet-approval-csv-report.models';
export const useTimesheetStore = defineStore('timesheet', () => {
const is_loading = ref<boolean>(false);
const pay_period = ref<PayPeriod>(default_pay_period);
const pay_period_overviews = ref<PayPeriodOverview[]>([default_pay_period_overview,]);
const current_pay_period_overview = ref<PayPeriodOverview>(default_pay_period_overview);
const pay_period_overviews = ref<TimesheetOverview[]>([default_pay_period_overview,]);
const current_pay_period_overview = ref<TimesheetOverview>(default_pay_period_overview);
const pay_period_details = ref<PayPeriodDetails>(default_pay_period_details);
const pay_period_report = ref();
const is_calendar_limit = computed(() =>