BREAKING(refactor): more refactor to streamline and standardize approvals and timesheet, pull to get expense changes

This commit is contained in:
Nicolas Drolet 2025-10-02 16:09:18 -04:00
parent 655a7ecff1
commit 00f5565fe5
49 changed files with 1415 additions and 1845 deletions

View File

@ -1,8 +1,8 @@
<script setup lang="ts">
const { title, startDate = "", endDate = "" } = defineProps<{
title: string;
startDate: string;
endDate: string;
startDate?: string;
endDate?: string;
}>();
const date_format_options = { day: 'numeric', month: 'long', year: 'numeric', };

View File

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

View File

@ -5,7 +5,7 @@
import { useI18n } from 'vue-i18n';
import { useQuasar } from 'quasar';
import { Chart as ChartJS, Title, Tooltip, Legend, BarElement, CategoryScale, LinearScale, type ChartData, type ChartOptions, type Plugin, type ChartDataset } from 'chart.js';
import type { PayPeriodEmployeeDetails } from 'src/modules/timesheet-approval/types/pay-period-employee-details';
import type { TimesheetDetails } from 'src/modules/timesheets/models/pay-period-details.models';
const { t } = useI18n();
const $q = useQuasar();
@ -16,7 +16,7 @@
ChartJS.defaults.color = $q.dark.isActive ? '#F5F5F5' : '#616161';
const props = defineProps<{
rawData: PayPeriodEmployeeDetails | undefined;
rawData: TimesheetDetails | undefined;
options?: ChartOptions<"bar"> | undefined;
plugins?: Plugin<"bar">[] | undefined;
}>();

View File

@ -5,15 +5,15 @@
import DetailedDialogChartHoursWorked from 'src/modules/timesheet-approval/components/graphs/detailed-chart-hours-worked.vue';
import DetailedDialogChartShiftTypes from 'src/modules/timesheet-approval/components/graphs/detailed-chart-shift-types.vue';
import DetailedDialogChartExpenses from 'src/modules/timesheet-approval/components/graphs/detailed-chart-expenses.vue';
import type { TimesheetApprovalOverviewCrewMember } from 'src/modules/timesheet-approval/models/timesheet-approval-overview.models';
import type { TimesheetDetails } from 'src/modules/timesheets/models/timesheet.models';
import type { PayPeriodOverview } from 'src/modules/timesheet-approval/models/pay-period-overview.models';
import type { PayPeriodDetails } from 'src/modules/timesheets/models/pay-period-details.models';
const dialog_model = defineModel<boolean>('dialog', { default: false });
defineProps<{
isLoading: boolean;
employeeOverview: TimesheetApprovalOverviewCrewMember;
timesheetDetails: TimesheetDetails;
payPeriodOverview: PayPeriodOverview;
payPeriodDetails: PayPeriodDetails;
}>();
// const timesheet_store = useTimesheetStore();
@ -52,7 +52,7 @@
v-if="!isLoading"
class="text-h5 text-weight-bolder text-center text-primary q-pa-none text-uppercase col-auto"
>
<span> {{ timesheetDetails.employee_full_name }} </span>
<span> {{ payPeriodDetails.employee_full_name }} </span>
<q-separator
spaced
@ -114,7 +114,7 @@
style="min-height: 300px;"
>
<DetailedDialogChartHoursWorked
:raw-data="timesheetDetails"
:raw-data="payPeriodDetails"
class="col-7"
/>
@ -125,7 +125,7 @@
<div class="column col justify-center no-wrap q-pa-none">
<DetailedDialogChartShiftTypes
:raw-data="employeeOverview"
:raw-data="payPeriodOverview"
class="col-5"
/>
@ -135,7 +135,7 @@
/>
<DetailedDialogChartExpenses
:raw-data="timesheetDetails"
:raw-data="payPeriodDetails"
class="col"
/>
</div>

View File

@ -1,17 +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 { date } from "quasar";
export const useTimesheetApprovalApi = () => {
const timesheet_store = useTimesheetStore();
const auth_store = useAuthStore();
const getPayPeriodOverviewByDate = async (date_string: string): Promise<void> => {
const getPayPeriodOverviewsByDate = async (date_string: string): Promise<void> => {
const success = await timesheet_store.getPayPeriodByDateOrYearAndNumber(date_string);
if (success) {
await timesheet_store.getPayPeriodEmployeeOverviewListBySupervisorEmail(
await timesheet_store.getPayPeriodOverviewsBySupervisorEmail(
timesheet_store.pay_period.pay_year,
timesheet_store.pay_period.pay_period_no,
auth_store.user.email
@ -19,35 +18,7 @@ export const useTimesheetApprovalApi = () => {
}
};
/* This method attempts to get the next or previous pay period.
It checks if pay period number is within a certain range, adjusts pay period and year accordingly.
It then requests the matching pay period object to set as current pay period from server.
If successful, it then requests pay period overviews from that new pay period, using either the current user or
any other supervisor email provided. */
const getNextOrPreviousPayPeriodOverviewList = async (direction: number, supervisor_email?: string): Promise<void> => {
const email = supervisor_email ?? auth_store.user.email;
let new_pay_period_no = timesheet_store.pay_period.pay_period_no + direction;
let new_pay_year = timesheet_store.pay_period.pay_year;
if (new_pay_period_no > 26) {
new_pay_period_no = 1;
new_pay_year += 1;
}
if (new_pay_period_no < 1) {
new_pay_period_no = 26;
new_pay_year -= 1;
}
const success = await timesheet_store.getPayPeriodByDateOrYearAndNumber(new_pay_year, new_pay_period_no);
if (success) {
await timesheet_store.getPayPeriodEmployeeOverviewListBySupervisorEmail(new_pay_year, new_pay_period_no, email);
}
};
const getTimesheetApprovalCSVReport = async ( report_filter_company: boolean[], report_filter_type: boolean[] ) => {
const getTimesheetApprovalCSVReport = async ( report_filter_company: boolean[], report_filter_type: boolean[], year?: number, period_number?: number ) => {
const [ targo, solucom ] = report_filter_company;
const [ shifts, expenses, holiday, vacation ] = report_filter_type;
const options = {
@ -55,12 +26,15 @@ export const useTimesheetApprovalApi = () => {
companies: { targo, solucom },
} as TimesheetApprovalCSVReportFilters;
await timesheet_store.getTimesheetApprovalCSVReport(options);
await timesheet_store.getPayPeriodReportByYearAndPeriodNumber(
year ?? timesheet_store.pay_period.pay_year,
period_number ?? timesheet_store.pay_period.pay_period_no,
options
);
};
return {
getPayPeriodOverviewByDate,
getNextOrPreviousPayPeriodOverviewList,
getPayPeriodOverviewsByDate,
getTimesheetApprovalCSVReport,
}
};

View File

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

View File

@ -1,102 +0,0 @@
export interface TimesheetApprovalOverviewCrew {
pay_period_no: number;
pay_year: number;
payday: string;
period_start: string;
period_end: string;
label: string;
overview_crew: TimesheetApprovalOverviewCrewMember[];
};
export interface TimesheetApprovalOverviewCrewMember {
email: string;
employee_name: string;
regular_hours: number;
evening_hours: number;
emergency_hours: number;
overtime_hours: number;
total_hours: number;
expenses: number;
mileage: number;
is_approved: boolean;
};
export const default_timesheet_approval_overview_crew_member: TimesheetApprovalOverviewCrewMember = {
email: '',
employee_name: '',
regular_hours: -1,
evening_hours: -1,
emergency_hours: -1,
overtime_hours: -1,
total_hours: -1,
expenses: -1,
mileage: -1,
is_approved: false
}
export const default_timesheet_approval_overview_crew: TimesheetApprovalOverviewCrew = {
pay_period_no: -1,
pay_year: -1,
payday: '',
period_start: '',
period_end: '',
label: '',
overview_crew: []
}
export const timesheet_approval_overview_crew_columns = [
{
name: 'employee_name',
label: 'timesheet_approvals.table.full_name',
field: 'employee_name',
sortable: true
},
{
name: 'email',
label: 'timesheet_approvals.table.email',
field: 'email',
sortable: true,
},
{
name: 'regular_hours',
label: 'shared.shift_type.regular',
field: 'regular_hours',
sortable: true,
},
{
name: 'evening_hours',
label: 'shared.shift_type.evening',
field: 'evening_hours',
sortable: true,
},
{
name: 'emergency_hours',
label: 'shared.shift_type.emergency',
field: 'emergency_hours',
sortable: true,
},
{
name: 'overtime_hours',
label: 'shared.shift_type.overtime',
field: 'overtime_hours',
sortable: true,
},
{
name: 'expenses',
label: 'timesheet_approvals.table.expenses',
field: 'expenses',
sortable: true,
},
{
name: 'mileage',
label: 'timesheet_approvals.table.mileage',
field: 'mileage',
sortable: true,
},
{
name: 'is_approved',
label: 'timesheet_approvals.table.is_approved',
field: 'is_approved',
sortable: true,
}
];

View File

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

View File

@ -1,34 +1,34 @@
<script setup lang="ts">
import type { TimesheetExpense } from '../../types/expense.interfaces';
import type { ExpenseType } from '../../types/expense.types';
/* eslint-disable */
<script
setup
lang="ts"
>
import type { ExpenseType, Expense } from 'src/modules/timesheets/models/expense.models';
import { ref } from 'vue';
//---------------- v-models ------------------
const draft = defineModel<Partial<TimesheetExpense>>('draft');
const files = defineModel<File[] | null>('files');
const datePickerOpen = defineModel<boolean | null>('datePickerOpen', {default: false });
const files = defineModel<File[] | null>('files');
const is_navigator_open = ref(false);
//------------------ props ------------------
const props = defineProps<{
type_options: { label: string; value: ExpenseType }[];
show_amount: boolean;
is_readonly: boolean;
rules: {
typeRequired: (val: unknown) => true | string;
amountRequired: (val: unknown) => true | string;
mileageRequired: (val: unknown) => true | string;
commentRequired: (val: unknown) => true | string;
commentTooLong: (val: unknown) => true | string;
};
comment_max_length: number;
setType: (val: ExpenseType) => void;
}>();
//------------------ props ------------------
defineProps<{
//------------------ emits ------------------
const emit = defineEmits<{
(e: 'submit'): void;
}>();
type_options: { label: string; value: ExpenseType }[];
show_amount: boolean;
is_readonly: boolean;
rules: {
typeRequired: (val: unknown) => true | string;
amountRequired: (val: unknown) => true | string;
mileageRequired: (val: unknown) => true | string;
commentRequired: (val: unknown) => true | string;
commentTooLong: (val: unknown) => true | string;
};
comment_max_length: number;
setType: (val: ExpenseType) => void;
}>();
//------------------ emits ------------------
const emit = defineEmits<{
'submit': [void];
}>();
</script>
<template>
@ -38,7 +38,7 @@ const emit = defineEmits<{
@submit.prevent="emit('submit')"
>
<div class="text-subtitle2 q-py-sm">
{{ $t('timesheet.expense.add_expense')}}
{{ $t('timesheet.expense.add_expense') }}
</div>
<div class="row justify-between">
@ -53,22 +53,22 @@ const emit = defineEmits<{
color="primary"
:label="$t('timesheet.expense.date')"
>
<template #before>
<q-btn
push
dense
icon="event"
color="primary"
@click="datePickerOpen = true"
/>
<q-dialog v-model="datePickerOpen">
<q-date
v-model="draft!.date"
@update:model-value="datePickerOpen = false"
mask="YYYY-MM-DD"
<template #before>
<q-btn
push
dense
icon="event"
color="primary"
@click="datePickerOpen = true"
/>
</q-dialog>
</template>
<q-dialog v-model="datePickerOpen">
<q-date
v-model="draft!.date"
@update:model-value="datePickerOpen = false"
mask="YYYY-MM-DD"
/>
</q-dialog>
</template>
</q-input>
<!-- expenses type selection -->
@ -82,7 +82,8 @@ const emit = defineEmits<{
emit-value
map-options
:label="$t('timesheet.expense.type')"
:rules="[ rules.typeRequired ]"
:rules="[rules.typeRequired]"
:option-label="label => $t(label)"
@update:model-value="val => setType(val as ExpenseType)"
/>
@ -101,7 +102,7 @@ const emit = defineEmits<{
:label="$t('timesheet.expense.amount')"
suffix="$"
lazy-rules="ondemand"
:rules="[ rules.amountRequired ]"
:rules="[rules.amountRequired]"
/>
</template>
@ -120,7 +121,7 @@ const emit = defineEmits<{
:label="$t('timesheet.expense.mileage')"
suffix="km"
lazy-rules="ondemand"
:rules="[ rules.mileageRequired ]"
:rules="[rules.mileageRequired]"
/>
</template>
@ -137,7 +138,7 @@ const emit = defineEmits<{
:counter="true"
:maxlength="comment_max_length"
lazy-rules="ondemand"
:rules="[ rules.commentRequired, rules.commentTooLong ]"
:rules="[rules.commentRequired, rules.commentTooLong]"
>
<template #label>
<span class="text-weight-bold ">
@ -163,7 +164,6 @@ const emit = defineEmits<{
name="attach_file"
size="sm"
color="primary"
/>
</template>
</q-file>

View File

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

View File

@ -0,0 +1,121 @@
<script
setup
lang="ts"
>
import { useI18n } from 'vue-i18n';
import { computed, ref } from 'vue';
import { useExpensesStore } from 'src/stores/expense-store';
import { useExpenseForm } from '../../composables/use-expense-form';
import { COMMENT_MAX_LENGTH } from '../../constants/expense.constants';
import ExpenseList from './expense-list.vue';
import ExpenseCrudDialogForm from 'src/modules/timesheets/components/expenses/expense-crud-dialog-form.vue';
import { computeExpenseTotals, makeExpenseRules, buildExpenseSavePayload } from '../../utils/expense.util';
import { EXPENSE_TYPE } from 'src/modules/timesheets/models/expense.models';
import type { Expense, ExpenseType, PayPeriodExpenses } from 'src/modules/timesheets/models/expense.models';
import { toQSelectOptions } from 'src/utils/to-qselect-options';
const { t } = useI18n();
const rules = makeExpenseRules(t, COMMENT_MAX_LENGTH);
const expense_store = useExpensesStore();
const { employeeEmail } = defineProps<{
employeeEmail: string;
}>();
const type_options = toQSelectOptions<ExpenseType>(EXPENSE_TYPE, 'timesheet.expense.types.');
//------------------ refs and computed ------------------
const files = ref<File[] | null>(null);
const { validateAnd } = useExpenseForm();
const totals = computed(() => computeExpenseTotals(expenses));
</script>
<template>
<q-dialog
v-model="expense_store.is_open"
persistent
>
<q-card
class="q-pa-md"
style=" min-width: 70vw;"
>
<q-inner-loading :showing="expense_store.is_loading">
<q-spinner size="32px" />
</q-inner-loading>
<q-card-section>
<!-- <q-banner
v-if="expenses_error"
dense
class="bg-red-2 col-auto text-negative q-mt-sm"
>
{{ expenses_error }}
</q-banner> -->
<!-- header (title with totals)-->
<q-item class="row justify-between">
<q-item-label
header
class="text-h6 col-auto"
>
{{ $t('timesheet.expense.title') }}
</q-item-label>
<q-item-section class="items-center col-auto">
<q-badge
lines="1"
class="q-pa-sm q-px-md"
:label="$t('timesheet.expense.total_amount') + ': ' + totals.amount.toFixed(2)"
/>
<q-separator spaced />
<q-badge
lines="2"
class="q-pa-sm q-px-md"
:label="$t('timesheet.expense.total_mileage') + ': ' + totals.mileage.toFixed(1)"
/>
</q-item-section>
</q-item>
<ExpenseList
:items="expense_store.current_expenses"
@remove="expense_store.upsertOrDeletePayPeriodExpenseByEmployeeEmail(employeeEmail, expense_store.current_expenses.expenses)"
/>
<ExpenseCrudDialogForm
:current-expenses:"expense_store.current_expenses.exp"
v-model:files="files"
:is_readonly="expense_store.current_expenses.is_approved"
:type_options="type_options"
:rules="rules"
:comment_max_length="COMMENT_MAX_LENGTH"
:set-type="setType"
@submit="expense_store.upsertOrDeletePayPeriodExpenseByEmployeeEmail(employeeEmail, expense_store.current_expenses.expenses)"
/>
<q-separator spaced />
</q-card-section>
<q-card-actions align="right">
<!-- close btn -->
<q-btn
flat
class="col-auto q-mr-sm"
color="primary"
:label="$t('timesheet.cancel_button')"
@click="close"
/>
<!-- save btn -->
<q-btn
push
color="primary"
class="col-auto"
:disable="pay_period_expenses.is_approved || expenses.length === 0"
:label="$t('timesheet.save_button')"
@click="onSave"
/>
</q-card-actions>
</q-card>
</q-dialog>
</template>

View File

@ -1,164 +0,0 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import { computed, ref } from 'vue';
import { useExpenseForm } from '../../composables/use-expense-form';
import { useExpenseDraft } from '../../composables/use-expense-draft';
import { useExpenseItems } from '../../composables/use-expense-items';
import { COMMENT_MAX_LENGTH } from '../../constants/expense.constants';
import { useToggle } from 'src/modules/shared/composables/use-toggle';
import ExpenseList from './expense-list.vue';
import ExpenseForm from './expense-form.vue';
import {
buildExpenseTypeOptions,
computeExpenseTotals,
makeExpenseRules,
buildExpenseSavePayload
} from '../../utils/expense.util';
import { EXPENSE_TYPE } from '../../types/expense.types';
import { ExpensesValidationError } from '../../types/expense-validation.interface';
import type { TimesheetExpense } from '../../types/expense.interfaces';
/* eslint-disable */
const { t , locale } = useI18n();
const rules = makeExpenseRules(t, COMMENT_MAX_LENGTH);
//------------------ props ------------------
const props = defineProps<{
pay_period_no: number;
pay_year: number;
email: string;
is_approved?: boolean;
initial_expenses?: TimesheetExpense[];
}>();
//------------------ emits ------------------
const emit = defineEmits<{
(e:'close'): void;
(e: 'save', payload: {
pay_period_no: number;
pay_year: number;
email: string;
expenses: TimesheetExpense[];
}): void;
(e: 'error', err: ExpensesValidationError): void;
}>();
//------------------ q-select mapper ------------------
const type_options = computed(()=> {
void locale.value;
return buildExpenseTypeOptions(EXPENSE_TYPE, t);
})
//------------------ refs and computed ------------------
const files = ref<File[] | null>(null);
const is_readonly = computed(()=> !!props.is_approved);
const { state: is_open_date_picker } = useToggle();
const { draft, setType, reset, showAmount } = useExpenseDraft();
const { validateAnd } = useExpenseForm();
const { items, addFromDraft, removeAt, validateAll, payload } = useExpenseItems({
initial_expenses: props.initial_expenses,
draft,
is_approved: is_readonly,
});
const totals = computed(()=> computeExpenseTotals(items.value));
//------------------ actions ------------------
const onSave = () => {
try {
validateAll();
reset();
emit('save', buildExpenseSavePayload({
pay_period_no: props.pay_period_no,
pay_year: props.pay_year,
email: props.email,
expenses: payload(),
}));
} catch(err: any) {
const e = err instanceof ExpensesValidationError
? err
: new ExpensesValidationError({
status_code: 400,
message: String(err?.message || err)
});
emit('error', e);
}
};
const onFormSubmit = async () => {
try {
await validateAnd(async () => {
addFromDraft();
reset();
});
} catch (err: any) {
const e = err instanceof ExpensesValidationError
? err
: new ExpensesValidationError({
status_code: 400,
message: String(err?.message || err)
});
emit('error', e);
}
};
const onClose = () => emit('close');
</script>
<template>
<div>
<!-- header (title with totals)-->
<q-item class="row justify-between">
<q-item-label header class="text-h6 col-auto">
{{ $t('timesheet.expense.title') }}
</q-item-label>
<q-item-section class="items-center col-auto">
<q-badge lines="1" class="q-pa-sm q-px-md" :label="$t('timesheet.expense.total_amount') + ': ' + totals.amount.toFixed(2)"/>
<q-separator spaced/>
<q-badge lines="2" class="q-pa-sm q-px-md" :label="$t('timesheet.expense.total_mileage') + ': ' + totals.mileage.toFixed(1)"/>
</q-item-section>
</q-item>
<ExpenseList
:items="items"
:is_readonly="is_readonly"
@remove="removeAt"
/>
<ExpenseForm
v-model:draft="draft"
v-model:files="files"
v-model:date-picker-open="is_open_date_picker"
:type_options="type_options"
:show_amount="showAmount"
:is_readonly="is_readonly"
:rules="rules"
:comment_max_length="COMMENT_MAX_LENGTH"
:set-type="setType"
@submit="onFormSubmit"
/>
<q-separator spaced/>
<div class="row col-auto justify-end">
<!-- close btn -->
<q-btn
flat
class="q-mr-sm"
color="primary"
:label="$t('timesheet.cancel_button')"
@click="onClose"
/>
<!-- save btn -->
<q-btn
color="primary"
unelevated
push
:disable="is_readonly || items.length === 0"
:label="$t('timesheet.save_button')"
@click="onSave"
/>
</div>
</div>
</template>

View File

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

View File

@ -1,23 +1,23 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import type { ShiftLegendItem } from '../../types/shift.types';
import type { ShiftLegendItem } from 'src/modules/timesheets/models/shift.models';
const { t } = useI18n();
const props = defineProps<{ isLoading: boolean; }>();
const legend: ShiftLegendItem[] = [
{type:'REGULAR' , color: 'secondary', label_key: 'timesheet.shift.types.REGULAR', text_color: 'grey-8'},
{type:'EVENING' , color: 'warning' , label_key: 'timesheet.shift.types.EVENING'},
{type:'EMERGENCY', color: 'amber-10' , label_key: 'timesheet.shift.types.EMERGENCY'},
{type:'OVERTIME' , color: 'negative' , label_key: 'timesheet.shift.types.OVERTIME'},
{type:'VACATION' , color: 'purple-10', label_key: 'timesheet.shift.types.VACATION'},
{type:'HOLIDAY' , color: 'purple-8' , label_key: 'timesheet.shift.types.HOLIDAY'},
{type:'SICK' , color: 'grey-8' , label_key: 'timesheet.shift.types.SICK'},
{type:'REGULAR' , color: 'secondary', label_type: 'timesheet.shift.types.REGULAR', text_color: 'grey-8'},
{type:'EVENING' , color: 'warning' , label_type: 'timesheet.shift.types.EVENING'},
{type:'EMERGENCY', color: 'amber-10' , label_type: 'timesheet.shift.types.EMERGENCY'},
{type:'OVERTIME' , color: 'negative' , label_type: 'timesheet.shift.types.OVERTIME'},
{type:'VACATION' , color: 'purple-10', label_type: 'timesheet.shift.types.VACATION'},
{type:'HOLIDAY' , color: 'purple-8' , label_type: 'timesheet.shift.types.HOLIDAY'},
{type:'SICK' , color: 'grey-8' , label_type: 'timesheet.shift.types.SICK'},
]
const shift_type_legend = computed(()=>
legend.map(item => ({ ...item, label: t(item.label_key)} ))
legend.map(item => ({ ...item, label: t(item.label_type)} ))
);
</script>

View File

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

View File

@ -1,46 +1,49 @@
<script setup lang="ts">
import type { PayPeriod } from 'src/modules/shared/types/pay-period-interface';
import detailedShiftListHeader from './detailed-shift-list-header.vue';
import detailedShiftListRow from './detailed-shift-list-row.vue';
import { date } from 'quasar';
import type { TimesheetPayPeriodDetailsOverview } from '../../types/timesheet.interfaces';
import type { Shift } from '../../types/shift.interfaces';
import { default_shift } from '../../types/shift.defaults';
<script
setup
lang="ts"
>
import { date } from 'quasar';
import ShiftListHeader from 'src/modules/timesheets/components/shift/shift-list-header.vue';
import ShiftListRow from 'src/modules/timesheets/components/shift/shift-list-row.vue';
import ShiftListLegend from 'src/modules/timesheets/components/shift/shift-list-legend.vue';
import PayPeriodNavigator from 'src/modules/shared/components/pay-period-navigator.vue';
import { useShiftStore } from 'src/stores/shift-store';
import { useTimesheetApi } from 'src/modules/timesheets/composables/api/use-timesheet-api';
import { type Shift, default_shift } from 'src/modules/timesheets/models/shift.models';
import type { PayPeriodDetails } from 'src/modules/timesheets/models/pay-period-details.models';
import type { PayPeriod } from 'src/modules/shared/types/pay-period-interface';
const props = defineProps<{
rawData: TimesheetPayPeriodDetailsOverview;
rawData: PayPeriodDetails;
currentPayPeriod: PayPeriod;
}>();
const emit = defineEmits<{
'request-add' : [payload: { date: string }];
'request-edit' : [payload: { date: string; shift: Shift }];
'request-delete' : [payload: { date: string; shift: Shift }];
// 'save-comment' : [payload: { date: string; shift: Shift; comment: string }];
}>();
const timesheet_api = useTimesheetApi();
const { openCreate, openDelete, openUpdate } = useShiftStore();
const get_date_from_short = (short_date: string):
Date => new Date(props.currentPayPeriod.pay_year.toString() + '/' + short_date);
const to_iso_date = (short_date: string):
string => date.formatDate(get_date_from_short(short_date), 'YYYY-MM-DD');
const get_date_from_short = (short_date: string): Date => {
return new Date(props.currentPayPeriod.pay_year.toString() + '/' + short_date);
};
const shifts_or_placeholder = (shifts: Shift[]):
Shift[] => { return shifts.length > 0 ? shifts : [default_shift]; };
const to_iso_date = (short_date: string): string => {
return date.formatDate(get_date_from_short(short_date), 'YYYY-MM-DD');
};
const shifts_or_placeholder = (shifts: Shift[]): Shift[] => {
return shifts.length > 0 ? shifts : [default_shift];
};
const getDate = (shift_date: string): Date => {
return new Date(props.currentPayPeriod.pay_year.toString() + '/' + shift_date);
};
const on_request_add = (iso_date: string) => emit('request-add', { date: iso_date });
const on_request_edit = (iso_date: string, shift: Shift) => emit('request-edit', { date: iso_date, shift });
const on_request_delete = (iso_date: string, shift: Shift) => emit('request-delete', { date: iso_date, shift });
// const on_save_comment = (iso_date: string, shift: Shift, comment: string) => emit('save-comment', { date: iso_date, shift, comment });
</script>
<template>
<!-- shift's colored legend -->
<ShiftListLegend :is-loading="false" />
<div
v-for="week, index in props.rawData"
v-for="week, index in props.rawData.weeks"
:key="index"
class="q-px-xs q-pt-xs rounded-5 col"
>
@ -51,15 +54,14 @@ import { default_shift } from '../../types/shift.defaults';
bordered
class="row items-center rounded-10 q-mb-xs"
>
<!-- Dates column -->
<q-card-section class="col-auto q-pa-xs text-white">
<div
class="bg-primary rounded-10 q-pa-xs text-center"
>
<div class="bg-primary rounded-10 q-pa-xs text-center">
<q-item-label
style="font-size: 0.7em;"
class="text-uppercase"
>{{ $d(getDate(day.short_date), {weekday: $q.screen.lt.md ? 'short' : 'long'}) }}</q-item-label>
>{{ $d(getDate(day.short_date), { weekday: $q.screen.lt.md ? 'short' : 'long' }) }}</q-item-label>
<q-item-label
class="text-weight-bolder"
style="font-size: 2.5em; line-height: 90% !important;"
@ -67,19 +69,19 @@ import { default_shift } from '../../types/shift.defaults';
<q-item-label
style="font-size: 0.7em;"
class="text-uppercase"
>{{ $d(getDate(day.short_date), {month: $q.screen.lt.md ? 'short' : 'long'}) }}</q-item-label>
>{{ $d(getDate(day.short_date), { month: $q.screen.lt.md ? 'short' : 'long' }) }}</q-item-label>
</div>
</q-card-section>
<!-- List of shifts column -->
<q-card-section class="col q-pa-none">
<detailedShiftListHeader />
<detailedShiftListRow
<ShiftListHeader />
<ShiftListRow
v-for="shift, shift_index in shifts_or_placeholder(day.shifts)"
:key="shift_index"
:shift="shift"
@request-edit=" ({ shift }) => on_request_edit(to_iso_date(day.short_date), shift )"
@request-delete="({ shift }) => on_request_delete(to_iso_date(day.short_date), shift )"
@request-update="value => openUpdate(to_iso_date(day.short_date), value)"
@request-delete="value => openDelete(to_iso_date(day.short_date), value)"
/>
</q-card-section>
<!-- add shift btn column -->
@ -89,7 +91,7 @@ import { default_shift } from '../../types/shift.defaults';
color="primary"
icon="more_time"
class="q-pa-sm"
@click="on_request_add(to_iso_date(day.short_date))"
@click="openCreate(to_iso_date(day.short_date))"
/>
</q-card-section>
</q-card>

View File

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

View File

@ -0,0 +1,68 @@
<script
setup
lang="ts"
>
import ShiftList from 'src/modules/timesheets/components/shift/shift-list.vue';
import ShiftCrudDialog from 'src/modules/timesheets/components/shift/shift-crud-dialog.vue';
import ExpenseCrudDialog from 'src/modules/timesheets/components/expenses/expense-crud-dialog.vue';
import { useTimesheetStore } from 'src/stores/timesheet-store';
import { useTimesheetApi } from 'src/modules/timesheets/composables/api/use-timesheet-api';
import { useExpensesStore } from 'src/stores/expense-store';
import { onMounted } from 'vue';
const { openExpensesDialog } = useExpensesStore();
const { employeeEmail } = defineProps<{
employeeEmail: string;
}>();
const { pay_period, pay_period_details, is_loading } = useTimesheetStore();
const { getPayPeriodDetailsByDate, getPreviousPayPeriodDetails, getNextPayPeriodDetails } = useTimesheetApi();
onMounted(() => {
});
</script>
<template>
<q-card
flat
class=" col q-mt-md bg-secondary"
>
<q-inner-loading
:showing="is_loading"
color="primary"
/>
<q-card-section horizontal>
<!-- expenses button -->
<q-btn
color="primary"
unelevated
icon="receipt_long"
:label="$t('timesheet.expense.open_btn')"
@click="openExpensesDialog"
/>
<!-- navigation btn -->
<PayPeriodNavigator
@date-selected="getPayPeriodDetailsByDate"
@pressed-previous-button="getPreviousPayPeriodDetails"
@pressed-next-button="getNextPayPeriodDetails"
/>
</q-card-section>
<q-card-section>
<ShiftList
:raw-data="pay_period_details"
:current-pay-period="pay_period"
/>
</q-card-section>
</q-card>
<!-- dialog for Expenses or Shifts -->
<ExpenseCrudDialog :email="employeeEmail" />
<!-- shift crud dialog -->
<ShiftCrudDialog :employee-email="employeeEmail" />
</template>

View File

@ -1,81 +1,21 @@
import { api } from "src/boot/axios";
import { isProxy, toRaw } from "vue";
import { useTimesheetStore } from "src/stores/timesheet-store";
import { useExpenseItems } from "src/modules/timesheets/composables/use-expense-items";
import { normalizeExpense, validateExpenseUI } from "../../utils/expenses-validators";
import type { ExpenseType } from "../../types/expense.types";
import { ExpensesApiError } from "../../types/expense-validation.interface";
import type {
ExpensePayload,
PayPeriodExpenses,
TimesheetExpense,
UpsertExpensesBody,
UpsertExpensesResponse
} from "../../types/expense.interfaces";
import type { ExpensesApiError } from "src/modules/timesheets/models/expense.validation";
import type { Expense, ExpenseType, PayPeriodExpenses } from "src/modules/timesheets/models/expense.models";
/* eslint-disable */
const toPlain = <T extends object>(obj:T): T => {
const raw = isProxy(obj) ? toRaw(obj) : obj;
if( typeof (globalThis as any).structuredClone === 'function') {
return (globalThis as any).structuredClone(raw);
}
return JSON.parse(JSON.stringify(raw));
};
const { pay_period } = useTimesheetStore();
const expense_items = useExpenseItems(draft);
const normalizePayload = (expense: ExpensePayload): ExpensePayload => {
const exp = normalizeExpense(expense as unknown as TimesheetExpense);
const out: ExpensePayload = {
date: exp.date,
type: exp.type as ExpenseType,
comment: exp.comment || '',
};
if(typeof exp.amount === 'number') out.amount = exp.amount;
if(typeof exp.mileage === 'number') out.mileage = exp.mileage;
return out;
}
//PUT by employee_email, year and period no
export const putPayPeriodExpensesByEmployeeEmail = async (employee_email: string, expenses: Expense[]): Promise<PayPeriodExpenses> => {
const encoded_email = encodeURIComponent(employee_email);
const encoded_year = encodeURIComponent(String(pay_period.pay_year));
const encoded_pay_period_no = encodeURIComponent(String(pay_period.pay_period_no));
//GET by email, year and period no
export const getPayPeriodExpenses = async (
email: string,
pay_year: number,
pay_period_no: number
) : Promise<PayPeriodExpenses> => {
const encoded_email = encodeURIComponent(email);
const encoded_year = encodeURIComponent(String(pay_year));
const encoded_pay_period_no = encodeURIComponent(String(pay_period_no));
const flat_expenses = expenses.map(expenses): [];
try {
const { data } = await api.get<PayPeriodExpenses>(`/expenses/${encoded_email}/${encoded_year}/${encoded_pay_period_no}`);
const items = Array.isArray(data.expenses) ? data.expenses.map(normalizeExpense) : [];
return {
...data,
expenses: items,
};
} catch(err:any) {
const status_code: number = err?.response?.status ?? 500;
const data = err?.response?.data ?? {};
throw new ExpensesApiError({
status_code,
error_code: data.error_code,
message: data.message || data.error || err.message,
context: data.context,
});
}
};
//PUT by email, year and period no
export const putPayPeriodExpenses = async (
email: string,
pay_year: number,
pay_period_no: number,
expenses: TimesheetExpense[]
): Promise<PayPeriodExpenses> => {
const encoded_email = encodeURIComponent(email);
const encoded_year = encodeURIComponent(String(pay_year));
const encoded_pay_period_no = encodeURIComponent(String(pay_period_no));
const plain = Array.isArray(expenses) ? expenses.map(toPlain): [];
const normalized: ExpensePayload[] = plain.map((exp) => {
const normalized: Expense[] = plain.map((exp) => {
const norm = normalizeExpense(exp as TimesheetExpense);
validateExpenseUI(norm, 'expense_item');
return normalizePayload(norm as unknown as ExpensePayload);
@ -85,10 +25,10 @@ export const putPayPeriodExpenses = async (
try {
const { data } = await api.put<UpsertExpensesResponse>(
`/expenses/${encoded_email}/${encoded_year}/${encoded_pay_period_no}`,
body,
{ headers: {'Content-Type': 'application/json'}}
);
// `/expenses/${encoded_email}/${encoded_year}/${encoded_pay_period_no}`,
// body,
// { headers: {'Content-Type': 'application/json'}}
// );
const items = Array.isArray(data?.data?.expenses)
? data.data.expenses.map(normalizeExpense)
@ -97,7 +37,7 @@ export const putPayPeriodExpenses = async (
...(data?.data ?? {
pay_period_no,
pay_year,
employee_email: email,
employee_email: employee_email,
is_approved: false,
expenses: [],
totals: {amount: 0, mileage: 0},
@ -117,12 +57,12 @@ export const putPayPeriodExpenses = async (
};
export const postPayPeriodExpenses = async (
email: string,
employee_email: string,
pay_year: number,
pay_period_no: number,
new_expenses: TimesheetExpense[]
): Promise<PayPeriodExpenses> => {
const encoded_email = encodeURIComponent(email);
const encoded_email = encodeURIComponent(employee_email);
const encoded_year = encodeURIComponent(String(pay_year));
const encoded_pp = encodeURIComponent(String(pay_period_no));
@ -148,7 +88,7 @@ export const postPayPeriodExpenses = async (
...(data?.data ?? {
pay_period_no,
pay_year,
employee_email: email,
employee_email: employee_email,
is_approved: false,
expenses: [],
totals: { amount: 0, mileage: 0 },

View File

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

View File

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

View File

@ -1,52 +1,55 @@
import { ref, type Ref } from "vue";
import { normalizeExpense, validateExpenseUI } from "../utils/expenses-validators";
import { normExpenseType } from "../utils/expense.util";
import type { TimesheetExpense } from "../types/expense.interfaces";
import 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";
type UseExpenseItemsParams = {
initial_expenses?: TimesheetExpense[] | null | undefined;
draft: Ref<Partial<TimesheetExpense>>;
is_approved: Ref<boolean> | boolean;
};
export const useExpenseItems = ({
initial_expenses,
draft,
is_approved
}: UseExpenseItemsParams) => {
const items = ref<TimesheetExpense[]>(
Array.isArray(initial_expenses) ? initial_expenses.map(normalizeExpense) : []
);
const expenses_store = useExpensesStore();
export const useExpenseItems = () => {
let expenses = unwrapAndClone(expenses_store.pay_period_expenses.expenses.map(normalizeExpense));
const normalizePayload = (expense: Expense): Expense => {
const exp = normalizeExpense(expense);
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: TimesheetExpense = normalizeExpense({
date: draft.value.date,
type: normExpenseType(draft.value.type),
...(typeof draft.value.amount === 'number' ? { amount: draft.value.amount }: {}),
...(typeof draft.value.mileage === 'number' ? { mileage: draft.value.mileage }: {}),
comment: String(draft.value.comment ?? '').trim(),
} as TimesheetExpense);
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');
items.value = [ ...items.value, candidate];
expenses = [ ...expenses, candidate];
};
const removeAt = (index: number) => {
const locked = typeof is_approved === 'boolean' ? is_approved : is_approved.value;
if(locked) return;
if(index < 0 || index >= items.value.length) return;
items.value = items.value.filter((_,i)=> i !== index);
if(index < 0 || index >= expenses.length) return;
expenses = expenses.filter((_,i)=> i !== index);
};
const validateAll = () => {
for (const expense of items.value) {
for (const expense of expenses) {
validateExpenseUI(expense, 'expense_item');
}
};
const payload = () => items.value.map(normalizeExpense);
const payload = () => expenses.map(normalizeExpense);
return {
items,
expenses,
addFromDraft,
removeAt,
validateAll,

View File

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

View File

@ -1,78 +1,46 @@
// export const EXPENSE_TYPE = [
// 'PER_DIEM',
// 'MILEAGE',
// 'EXPENSES',
// 'PRIME_GARDE',
// ] as const;
// export type ExpenseType = (typeof EXPENSE_TYPE)[number];
// export const TYPES_WITH_MILEAGE_ONLY: Readonly<ExpenseType[]> = ['MILEAGE'];
// export const TYPES_WITH_AMOUNT_ONLY: Readonly<ExpenseType[]> = [
// 'PER_DIEM',
// 'EXPENSES',
// 'PRIME_GARDE',
// ];
export type ExpenseType = 'PER_DIEM' | 'MILEAGE' | 'EXPENSES' | 'PRIME_GARDE';
export type ExpenseTotals = {
amount: number;
mileage: number;
};
// export type ExpenseSavePayload = {
// pay_period_no: number;
// pay_year: number;
// email: string;
// expenses: TimesheetExpense[];
// };
export const EXPENSE_TYPE: ExpenseType[] = ['PER_DIEM', 'MILEAGE', 'EXPENSES', 'PRIME_GARDE',];
export const TYPES_WITH_MILEAGE_ONLY: Readonly<ExpenseType[]> = ['MILEAGE'];
export const TYPES_WITH_AMOUNT_ONLY: Readonly<ExpenseType[]> = ['PER_DIEM', 'EXPENSES', 'PRIME_GARDE',];
export interface Expense {
// is_approved: boolean;
// comment: string;
// amount: number;
// supervisor_comment: string;
// }
// export interface TimesheetExpense {
date: string;
type: string;
amount?: number;
mileage?: number;
comment?: string;
date: string;
type: ExpenseType;
amount?: number;
mileage?: number;
comment: string;
supervisor_comment?: string;
is_approved?: boolean;
is_approved: boolean;
}
// export interface PayPeriodExpenses {
export interface TimesheetExpenses {
pay_period_no: number;
pay_year: number;
employee_email: string;
is_approved: boolean;
// expenses: TimesheetExpense[];
export type ExpenseTotals = {
amount: number;
mileage: number;
reimburseable_total?: number;
};
export interface PayPeriodExpenses {
is_approved: boolean;
expenses: Expense[];
totals?: {
amount: number;
mileage: number;
reimbursable_total?: number;
}
totals?: ExpenseTotals;
}
// export interface ExpensePayload{
// date: string;
// type: ExpenseType;
// amount?: number;
// mileage?: number;
// comment: string;
// }
export interface TimesheetDetailsWeekDayExpenses {
cash: Expense[];
km: Expense[];
[otherType: string]: Expense[];
}
// export interface UpsertExpensesBody {
// expenses: ExpensePayload[];
// }
export const default_expense: Expense = {
date: '',
type: 'EXPENSES',
amount: 0,
comment: '',
is_approved: false,
};
// export interface UpsertExpensesResponse {
// data: PayPeriodExpenses;
// }
export const default_pay_period_expenses: PayPeriodExpenses = {
is_approved: false,
expenses: [],
}

View File

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

View File

@ -0,0 +1,77 @@
import type { Shift } from "./shift.models";
import 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 {
cash: Expense[];
km: Expense[];
[otherType: string]: Expense[];
}
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 => ({
cash: [],
km: [],
});
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,82 +1,49 @@
// export const SHIFT_KEY = [
// 'REGULAR',
// 'EVENING',
// 'EMERGENCY',
// 'HOLIDAY',
// 'VACATION',
// 'SICK'
// ] as const;
export const SHIFT_TYPES = [
'REGULAR',
'EVENING',
'EMERGENCY',
'OVERTIME',
'HOLIDAY',
'VACATION',
'SICK'
];
// export type ShiftKey = typeof SHIFT_KEY[number];
export type ShiftType = 'REGULAR' | 'EVENING' | 'EMERGENCY' | 'OVERTIME' | 'HOLIDAY' | 'VACATION' | 'SICK' ;
// export type ShiftSelectOption = { value: ShiftKey; label: string };
// export type ShiftPayload = {
// start_time: string;
// end_time: string;
// type: ShiftKey;
// is_remote: boolean;
// comment?: string;
// }
export type ShiftKey = 'REGULAR' | 'EVENING' | 'EMERGENCY' | 'HOLIDAY' | 'VACATION' | 'SICK';
export type UpsertAction = 'created' | 'updated' | 'deleted';
export type UpsertAction = 'create' | 'update' | 'delete';
export type ShiftLegendItem = {
type: ShiftKey;
type: ShiftType;
color: string;
label_key: string;
label_type: string;
text_color?: string;
};
export interface Shift {
date: string;
type: ShiftKey;
type: ShiftType;
start_time: string;
end_time: string;
comment: string;
comment: string | undefined;
is_approved: boolean;
is_remote: boolean;
}
export interface UpsertShiftsResponse {
action: UpsertAction;
// day: DayShift[];
day: Shift[];
}
// export interface CreateShiftPayload {
// date: string;
// type: ShiftKey;
// start_time: string;
// end_time: string;
// comment?: string;
// is_remote?: boolean;
// }
// export interface CreateWeekShiftPayload {
// shifts: CreateShiftPayload[];
// }
// export interface UpsertShiftsBody {
// old_shift?: ShiftPayload;
// new_shift?: ShiftPayload;
// }
// export interface DayShift {
// start_time: string;
// end_time: string;
// type: string;
// is_remote: boolean;
// comment?: string | null;
// }
export interface UpsertShift {
old_shift?: Shift | undefined;
new_shift?: Shift | undefined;
}
export const default_shift: Readonly<Shift> = {
date: '',
start_time: '--:--',
end_time: '--:--',
type:'REGULAR',
type: 'REGULAR',
comment: '',
is_approved: false,
is_remote: false,

View File

@ -1,125 +0,0 @@
import type { Shift } from "./shift.models";
import type { Expense } from "src/modules/timesheets/models/expense.models";
// import type {
// TimesheetExpenseEntry,
// TimesheetShiftEntry,
// Week
// } from "./timesheet.types";
// export interface Timesheet {
// is_approved: boolean;
// start_day: string;
// end_day: string;
// label: string;
// shifts: TimesheetShiftEntry[];
// expenses: TimesheetExpenseEntry[];
// }
// export type TimesheetShiftEntry = {
// bank_type: string;
// date: string;
// start_time: string;
// end_time: string;
// comment: string;
// is_approved: boolean;
// is_remote: boolean;
// };
// export type TimesheetExpenseEntry = {
// bank_type: string;
// date: string;
// amount: number;
// km: number;
// comment: string;
// is_approved: boolean;
// supervisor_comment: string;
// };
export type Week<T> = {
sun: T;
mon: T;
tue: T;
wed: T;
thu: T;
fri: T;
sat: T;
};
export interface TimesheetDetails {
week1: TimesheetDetailsWeek;
week2: TimesheetDetailsWeek;
employee_full_name: string;
}
export interface TimesheetDetailsWeek {
is_approved: boolean;
shifts: Week<TimesheetDetailsWeekDayShifts>
expenses: Week<TimesheetDetailsWeekDayExpenses>;
}
export interface TimesheetDetailsWeekDayShifts {
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 TimesheetDetailsWeekDayExpenses {
cash: Expense[];
km: Expense[];
[otherType: string]: Expense[];
}
// export interface DailyExpense {
// is_approved: boolean;
// comment: string;
// amount: number;
// supervisor_comment: string;
// }
// export interface TimesheetPayPeriodDetailsOverview {
// week1: TimesheetDetailsWeek;
// week2: TimesheetDetailsWeek;
// }
const makeWeek = <T>(factory: ()=> T): Week<T> => ({
sun: factory(),
mon: factory(),
tue: factory(),
wed: factory(),
thu: factory(),
fri: factory(),
sat: factory(),
});
const emptyDailySchedule = (): TimesheetDetailsWeekDayShifts => ({
shifts: [],
regular_hours: 0,
evening_hours: 0,
emergency_hours: 0,
overtime_hours: 0,
total_hours: 0,
short_date: "",
break_duration: 0,
});
const emptyDailyExpenses = (): TimesheetDetailsWeekDayExpenses => ({
cash: [],
km: [],
});
export const defaultTimesheetDetailsWeek = (): TimesheetDetailsWeek => ({
is_approved: false,
shifts: makeWeek(emptyDailySchedule),
expenses: makeWeek(emptyDailyExpenses),
});
export const default_timesheet_details: TimesheetDetails = {
week1: defaultTimesheetDetailsWeek(),
week2: defaultTimesheetDetailsWeek(),
employee_full_name: "",
}

View File

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

View File

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

View File

@ -1,23 +1,14 @@
import { api } from "src/boot/axios";
import type { PayPeriod } from "src/modules/shared/types/pay-period-interface";
import type { PayPeriodEmployeeDetails } from "src/modules/timesheet-approval/types/pay-period-employee-details";
import type { PayPeriodOverview } from "src/modules/timesheet-approval/types/pay-period-overview";
import type { PayPeriodReportFilters } from "src/modules/timesheet-approval/types/pay-period-report";
import type { Timesheet } from "../types/timesheet.interfaces";
import type { CreateShiftPayload, CreateWeekShiftPayload } from "../types/shift.interfaces";
import type { UpsertShift } from "src/modules/timesheets/models/shift.models";
import type { PayPeriod } from "src/modules/shared/models/pay-period.models";
import type { PayPeriodDetails } from "src/modules/timesheets/models/pay-period-details.models";
import type { PayPeriodOverview } from "src/modules/timesheet-approval/models/pay-period-overview.models";
import type { PayPeriodExpenses } from "src/modules/timesheets/models/expense.models";
export const timesheetService = {
//GET
getTimesheetsByEmail: async ( email: string, offset = 0): Promise<Timesheet> => {
const response = await api.get(`/timesheets/${encodeURIComponent(email)}`, {params: offset ? { offset } : undefined});
return response.data as Timesheet;
},
//POST
createTimesheetShifts: async ( email: string, shifts: CreateShiftPayload[], offset = 0): Promise<Timesheet> => {
const payload: CreateWeekShiftPayload = { shifts };
const response = await api.post(`/timesheets/shifts/${encodeURIComponent(email)}`, payload, { params: offset ? { offset }: undefined });
return response.data as Timesheet;
getPayPeriodDetailsByEmployeeEmail: async (email: string): Promise<PayPeriodDetails> => {
const response = await api.get(`/timesheets/${encodeURIComponent(email)}`);
return response.data;
},
getPayPeriodByDate: async (date_string: string): Promise<PayPeriod> => {
@ -30,22 +21,18 @@ export const timesheetService = {
return response.data;
},
getPayPeriodEmployeeOverviews: async (year: number, period_number: number, supervisor_email: string): Promise<PayPeriodOverview> => {
// TODO: REMOVE MOCK DATA PEFORE PUSHING TO PROD
getPayPeriodOverviewsBySupervisorEmail: async (year: number, period_number: number, supervisor_email: string): Promise<PayPeriodOverview[]> => {
const response = await api.get(`pay-periods/${year}/${period_number}/${supervisor_email}`);
console.log('pay period data: ', response.data);
return response.data;
},
getTimesheetsByPayPeriodAndEmail: async (year: number, period_no: number, email: string): Promise<PayPeriodEmployeeDetails> => {
const response = await api.get('timesheets', { params: { year, period_no, email, }});
console.log('employee details: ', response.data);
getPayPeriodDetailsByPayPeriodAndEmployeeEmail: async (year: number, period_no: number, email: string): Promise<PayPeriodDetails> => {
const response = await api.get('timesheets', { params: { year, period_no, email, } });
return response.data;
},
getTimesheetApprovalCSVReport: async (year: number, period_number: number, report_filters?: PayPeriodReportFilters) => {
const response = await api.get(`csv/${year}/${period_number}`, { params: { report_filters, }});
return response.data;
upsertOrDeletePayPeriodDetailsByDateAndEmployeeEmail: async (email: string, payload: UpsertShift[] | PayPeriodExpenses, pay_period: PayPeriod, date?: string): Promise<PayPeriodDetails> => {
if (date) return (await api.put(`/shifts/upsert/${email}/${date}`, payload)).data;
else return (await api.put(`/expenses/${email}/${pay_period.pay_year}/${pay_period.pay_period_no}`, payload, { headers: {'Content-Type': 'application/json'}})).data;
},
};

View File

@ -1,6 +1,5 @@
import type { TimesheetExpense } from "../types/expense.interfaces";
import type { ExpenseSavePayload, ExpenseTotals, ExpenseType } from "../types/expense.types";
/* eslint-disable */
import type { Expense, ExpenseTotals, ExpenseType, PayPeriodExpenses } from "src/modules/timesheets/models/expense.models";
//------------------ normalization / icons ------------------
export const normExpenseType = (type: unknown): string =>
String(type ?? '').trim().toUpperCase();
@ -21,16 +20,8 @@ export const expenseTypeIcon = (type: unknown): string => {
);
};
//------------------ q-select options ------------------
export const buildExpenseTypeOptions = ( types: readonly ExpenseType[], t: (key:string) => string):
{ label: string; value: ExpenseType } [] =>
types.map((val)=> ({
label: t(`timesheet.expense.types.${val}`),
value: val,
}));
//------------------ totals ------------------
export const computeExpenseTotals = (items: readonly TimesheetExpense[]): ExpenseTotals =>
export const computeExpenseTotals = (items: readonly Expense[]): ExpenseTotals =>
items.reduce<ExpenseTotals>(
(acc, e) => ({
amount: acc.amount + (Number(e.amount) || 0),
@ -47,7 +38,7 @@ export const makeExpenseRules = (t: (key: string) => string, max_comment_char: n
const amountRequired = (val: unknown) => (isPresent(val)) || t('timesheet.expense.errors.amount_required_for_type');
const mileageRequired = (val: unknown) => (isPresent(val)) || t('timesheet.expense.erros.mileage_required_for_type');
const mileageRequired = (val: unknown) => (isPresent(val)) || t('timesheet.expense.errors.mileage_required_for_type');
const commentRequired = (val: unknown) => (String(val ?? '').trim().length > 0) || t('timesheet.expense.errors.comment_required');
@ -63,14 +54,11 @@ export const makeExpenseRules = (t: (key: string) => string, max_comment_char: n
};
//------------------ saving payload ------------------
export const buildExpenseSavePayload = (args: {
pay_period_no: number;
pay_year: number;
email: string;
expenses: TimesheetExpense[];
}): ExpenseSavePayload => ({
export const buildExpenseSavePayload = (args: PayPeriodExpenses): PayPeriodExpenses => ({
pay_period_no: args.pay_period_no,
pay_year: args.pay_year,
email: args.email,
employee_email: args.employee_email,
is_approved: args.is_approved ?? false,
expenses: args.expenses,
totals: computeExpenseTotals(args.expenses),
});

View File

@ -1,31 +1,29 @@
import { COMMENT_MAX_LENGTH, DATE_FORMAT_PATTERN } from "../constants/expense.constants";
import { ExpensesValidationError } from "../types/expense-validation.interface";
import type { TimesheetExpense } from "../types/expense.interfaces";
import {
type ExpenseType,
TYPES_WITH_AMOUNT_ONLY,
TYPES_WITH_MILEAGE_ONLY
} from "../types/expense.types";
import { COMMENT_MAX_LENGTH, DATE_FORMAT_PATTERN } from "src/modules/timesheets/constants/expense.constants";
import { ExpensesValidationError } from "src/modules/timesheets/models/expense.validation";
import { type Expense, type ExpenseType, TYPES_WITH_AMOUNT_ONLY, TYPES_WITH_MILEAGE_ONLY } from "src/modules/timesheets/models/expense.models";
//normalization helpers
export const toNumOrUndefined = (value: unknown): number | undefined => {
if(value === undefined || value === null || (typeof value === 'string' && value.trim() === '')) return undefined;
const num = Number(value);
return Number.isFinite(num) ? num : undefined;
};
export const normalizeComment = (input?: string): string | undefined => {
if(typeof input === 'undefined' || input === null) return undefined;
const trimmed = String(input).trim();
return trimmed.length ? trimmed : undefined;
};
export const normalizeType = (input: string): string => (input ?? '').trim().toUpperCase();
export const normalizeExpense = (expense: TimesheetExpense): TimesheetExpense => {
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),
@ -40,7 +38,7 @@ export const normalizeExpense = (expense: TimesheetExpense): TimesheetExpense =>
};
//UI validation error messages
export const validateExpenseUI = (raw: TimesheetExpense, label: string = 'expense'): void => {
export const validateExpenseUI = (raw: Expense, label: string = 'expense'): void => {
const expense = normalizeExpense(raw);
//Date input validation
@ -60,6 +58,7 @@ export const validateExpenseUI = (raw: TimesheetExpense, label: string = 'expens
context: { [label]: expense },
})
}
if((expense.comment.length ?? 0) > COMMENT_MAX_LENGTH) {
throw new ExpensesValidationError({
status_code: 400,
@ -100,7 +99,7 @@ export const validateExpenseUI = (raw: TimesheetExpense, label: string = 'expens
//type constraint validation
const type = expense.type as ExpenseType;
if(TYPES_WITH_MILEAGE_ONLY.includes(type) && !has_mileage) {
if( TYPES_WITH_MILEAGE_ONLY.includes(type) && !has_mileage ) {
throw new ExpensesValidationError({
status_code: 400,
message: 'timesheet.expense.errors.mileage_required_for_type',
@ -117,7 +116,7 @@ export const validateExpenseUI = (raw: TimesheetExpense, label: string = 'expens
};
//totals per pay-period
export const compute_expense_totals = (items: TimesheetExpense[]) => items.reduce(
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;

View File

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

View File

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

View File

@ -1,56 +1,73 @@
<script setup lang="ts">
import { useQuasar } from 'quasar';
import type { QVueGlobals } from 'quasar';
<script
setup
lang="ts"
>
import { useQuasar } from 'quasar';
import type { QVueGlobals } from 'quasar';
const q: QVueGlobals = useQuasar();
const q: QVueGlobals = useQuasar();
const clickNotify = () => {
q.notify({
message: 'Nick pinged you.',
})
}
const clickNotify = () => {
q.notify({
message: 'Nick pinged you.',
})
}
</script>
<template>
<q-page padding class="q-pa-md row items-center justify-center">
<q-card class="shadow-2 col-9 dark-font">
<q-img src="src/assets/line-truck-1.jpg">
<div class="absolute-bottom text-h5">
Welcome to App Targo!
</div>
</q-img>
<q-page
padding
class="q-pa-md row items-center justify-center"
>
<q-card class="shadow-2 col-9 dark-font">
<q-img src="src/assets/line-truck-1.jpg">
<div class="absolute-bottom text-h5">
Welcome to App Targo!
</div>
</q-img>
<q-card-section class="text-center">
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium,
totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta
sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia
consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui
dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora
incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum
exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem
vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui
dolorem eum fugiat quo voluptas nulla pariatur?
</q-card-section>
<q-card-section class="text-center">
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium,
totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta
sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia
consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui
dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora
incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum
exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem
vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum
qui
dolorem eum fugiat quo voluptas nulla pariatur?
</q-card-section>
<q-card-section class="text-center">
At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum deleniti atque corrupti quos dolores et quas molestias excepturi sint occaecati cupiditate non provident, similique sunt in culpa qui officia deserunt mollitia animi, id est laborum et dolorum fuga. Et harum quidem rerum facilis est et expedita distinctio. Nam libero tempore, cum soluta nobis est eligendi optio cumque nihil impedit quo minus id quod maxime placeat facere possimus, omnis voluptas assumenda est, omnis dolor repellendus. Temporibus autem quibusdam et aut officiis debitis aut rerum necessitatibus saepe eveniet ut et voluptates repudiandae sint et molestiae non recusandae. Itaque earum rerum hic tenetur a sapiente delectus, ut aut reiciendis voluptatibus maiores alias consequatur aut perferendis doloribus asperiores repellat.
</q-card-section>
<q-card-section class="text-center">
At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum
deleniti atque corrupti quos dolores et quas molestias excepturi sint occaecati cupiditate non
provident, similique sunt in culpa qui officia deserunt mollitia animi, id est laborum et dolorum fuga.
Et harum quidem rerum facilis est et expedita distinctio. Nam libero tempore, cum soluta nobis est
eligendi optio cumque nihil impedit quo minus id quod maxime placeat facere possimus, omnis voluptas
assumenda est, omnis dolor repellendus. Temporibus autem quibusdam et aut officiis debitis aut rerum
necessitatibus saepe eveniet ut et voluptates repudiandae sint et molestiae non recusandae. Itaque earum
rerum hic tenetur a sapiente delectus, ut aut reiciendis voluptatibus maiores alias consequatur aut
perferendis doloribus asperiores repellat.
</q-card-section>
<q-card-section class="text-center">
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
</q-card-section>
<q-card-section class="text-center">
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et
dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip
ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu
fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia
deserunt mollit anim id est laborum.
</q-card-section>
<q-separator />
<q-separator />
<q-card-actions align="center">
<q-btn color="primary" label="Click Me" @click="clickNotify" />
</q-card-actions>
</q-card>
</q-page>
<q-card-actions align="center">
<q-btn
color="primary"
label="Click Me"
@click="clickNotify"
/>
</q-card-actions>
</q-card>
</q-page>
</template>
<style scoped>
.dark-font {
color: #676;
}
</style>

View File

@ -1,88 +1,124 @@
import { ref } from "vue";
import { defineStore } from "pinia";
import { type PayPeriodExpenses } from "src/modules/timesheets/types/expense.interfaces";
import { ExpensesApiError } from "src/modules/timesheets/types/expense-validation.interface";
import { getPayPeriodExpenses, putPayPeriodExpenses } from "src/modules/timesheets/composables/api/use-expense-api";
import { useTimesheetStore } from "src/stores/timesheet-store";
import
import { default_expense, default_pay_period_expenses, type Expense, type PayPeriodExpenses } from "src/modules/timesheets/models/expense.models";
import { unwrapAndClone } from "src/utils/unwrap-and-clone";
const { pay_period } = useTimesheetStore();
/* eslint-disable */
export const useExpensesStore = defineStore('expenses', () => {
const is_dialog_open = ref(false);
const is_loading = ref(false);
const data = ref<PayPeriodExpenses | null>(null);
const error = ref<string | null>(null);
const is_open = ref(false);
const is_loading = ref(false);
const current_expenses = ref<PayPeriodExpenses>(default_pay_period_expenses);
const current_expense = ref<Expense>(default_expense);
const initial_expense = ref<Expense>(default_expense);
const error = ref<string | null>(null);
const setErrorFrom = (err: unknown, t?: (_key: string) => string) => {
const setErrorFrom = (err: unknown) => {
const e = err as any;
error.value = (err instanceof ExpensesApiError && t
? t(e.message): undefined)
|| e?.message
|| 'Unknown error';
error.value = e?.message || 'Unknown error';
};
const openDialog = async (
params: { email: string; pay_year: number; pay_period_no: number; t?: (_key: string)=> string}) => {
is_dialog_open.value = true;
is_loading.value = true;
error.value = null;
try {
const response = await getPayPeriodExpenses(
params.email,
params.pay_year,
params.pay_period_no,
);
data.value = response;
} catch (err) {
setErrorFrom(err, params.t);
data.value = {
pay_period_no: params.pay_period_no,
pay_year: params.pay_year,
employee_email: params.email,
is_approved: false,
expenses: [],
totals: { amount: 0, mileage: 0},
};
} finally {
is_loading.value = false;
}
}
const saveExpenses = async (payload: {
email: string;
pay_year: number;
pay_period_no: number;
expenses: any[]; t?: (_key: string) => string
}) => {
const open = async (employee_email: string) => {
is_open.value = true;
is_loading.value = true;
error.value = null;
error.value = null;
try {
const updated = await putPayPeriodExpenses(
payload.email,
payload.pay_year,
payload.pay_period_no,
payload.expenses
);
data.value = updated;
is_dialog_open.value = false;
const response = await getPayPeriodExpenses(employee_email, pay_period.pay_year, pay_period.pay_period_no,);
current_expenses.value = response;
initial_expenses.value = unwrapAndClone(response);
} catch (err) {
setErrorFrom(err, payload.t);
setErrorFrom(err);
current_expenses.value = default_pay_period_expenses;
initial_expenses.value = default_pay_period_expenses;
} finally {
is_loading.value = false;
}
}
const getPayPeriodExpensesByEmployeeEmail = async (employee_email: string): Promise<PayPeriodExpenses> => {
const encoded_email = encodeURIComponent(employee_email);
const encoded_year = encodeURIComponent(String(pay_period.pay_year));
const encoded_pay_period_no = encodeURIComponent(String(pay_period.pay_period_no));
try {
const { data } = await api.get<PayPeriodExpenses>(`/expenses/${encoded_email}/${encoded_year}/${encoded_pay_period_no}`);
const items = Array.isArray(data.expenses) ? data.expenses.map(normalizeExpense) : [];
return {
...data,
expenses: items,
};
} catch(err:any) {
const status_code: number = err?.response?.status ?? 500;
const data = err?.response?.data ?? {};
throw new ExpensesApiError({
status_code,
error_code: data.error_code,
message: data.message || data.error || err.message,
context: data.context,
});
}
};
const onSave = () => {
try {
validateAll();
reset();
emit('save', buildExpenseSavePayload({
pay_period_no: pay_period.pay_period_no,
pay_year: pay_period.pay_year,
employee_email: employeeEmail,
is_approved: false,
expenses: payload(),
}));
} catch (err: any) {
emit('error', toExpensesError(err));
}
};
const onFormSubmit = async () => {
try {
await validateAnd(async () => {
addFromDraft();
reset();
});
} catch (err: any) {
emit('error', toExpensesError(err));
}
};
const upsertOrDeletePayPeriodExpenseByEmployeeEmail = async (employee_email: string, expenses: Expense[]) => {
is_loading.value = true;
error.value = null;
try {
const updated = await putPayPeriodExpenses(employee_email, pay_period.pay_year, pay_period.pay_period_no, expenses);
pay_period_expenses.value = updated;
is_open.value = false;
} catch (err) {
setErrorFrom(err);
} finally {
is_loading.value = false;
}
};
const closeDialog = () => {
error.value = null;
is_dialog_open.value = false;
const close = () => {
error.value = null;
is_open.value = false;
};
return {
is_dialog_open,
is_open,
is_loading,
data,
current_expenses,
initial_expenses,
error,
openDialog,
saveExpenses,
closeDialog,
open,
upsertOrDeletePayPeriodExpenseByEmployeeEmail,
close,
};
});

View File

@ -1,50 +1,80 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import { toShiftPayload } from "src/modules/timesheets/utils/shift.util";
import type { FormMode } from "src/modules/timesheets/types/ui.types";
import type { ShiftPayload } from "src/modules/timesheets/types/shift.types";
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 UpsertAction, type Shift, UpsertShift } from "src/modules/timesheets/models/shift.models";
import { GenericApiError } from "src/modules/timesheets/models/expense.validation";
/* eslint-disable */
export const useShiftStore = defineStore('shift', () => {
const is_open = ref(false);
const mode = ref<FormMode>('create');
const date_iso = ref<string>('');
const initial_shift = ref<ShiftPayload | null>(null);
const is_open = ref(false);
const mode = ref<UpsertAction>('create');
const date_iso = ref<string>('');
const current_shift = ref<Shift>(default_shift);
const initial_shift = ref<Shift>(default_shift);
const open = (nextMode: FormMode, date: string, payload: ShiftPayload | null) => {
mode.value = nextMode;
date_iso.value = date;
initial_shift.value = payload;
is_open.value = true;
const timesheet_store = useTimesheetStore();
const open = (next_mode: UpsertAction, 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, null);
open('create', date, default_shift, default_shift);
};
const openEdit = (date: string, shift: any) => {
open('edit', date, toShiftPayload(shift as any));
const openUpdate = (date: string, shift: Shift) => {
open('update', date, shift, unwrapAndClone(shift));
};
const openDelete = (date: string, shift: any) => {
open('delete', date, toShiftPayload(shift as any));
open('delete', date, default_shift, shift);
}
const close = () => {
is_open.value = false;
mode.value = 'create';
date_iso.value = '';
initial_shift.value = null;
is_open.value = false;
mode.value = 'create';
date_iso.value = '';
current_shift.value = default_shift;
initial_shift.value = default_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.upsertOrDeletePayPeriodShifts(encoded_email, encoded_date, [ upsert_shift, ]);
timesheet_store.pay_period_details = result;
} catch (err: any) {
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,
openEdit,
openUpdate,
openDelete,
close,
upsertOrDeleteShiftByEmployeeEmail,
};
})

View File

@ -3,21 +3,18 @@ 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_timesheet_approval_overview_crew, type TimesheetApprovalOverviewCrew } from "src/modules/timesheet-approval/models/timesheet-approval-overview.models";
// import type { Timesheet } from 'src/modules/timesheets/types/timesheet.interfaces';
import type { TimesheetDetails } from 'src/modules/timesheets/models/timesheet.models';
import { default_timesheet_details } from 'src/modules/timesheets/types/timesheet.defaults';
import { default_pay_period_overview, type PayPeriodOverview } from "src/modules/timesheet-approval/models/pay-period-overview.models";
import { default_pay_period, type PayPeriod } from 'src/modules/shared/types/pay-period-interface';
import type { PayPeriodReportFilters } from 'src/modules/timesheet-approval/types/pay-period-report';
import { default_pay_period_details, type PayPeriodDetails } from 'src/modules/timesheets/models/pay-period-details.models';
import { 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 timesheet_approval_overview_list = ref<TimesheetApprovalOverview[]>([]);
const timesheet_aproval_overview = ref<TimesheetApprovalOverview>(default_pay_period_employee_overview);
const pay_period_employee_details = ref<TimesheetDetails>(default_timesheet_details);
const pay_period_overviews = ref<PayPeriodOverview[]>([ default_pay_period_overview, ]);
const current_pay_period_overview = ref<PayPeriodOverview>(default_pay_period_overview);
const pay_period_details = ref<PayPeriodDetails>(default_pay_period_details);
const pay_period_report = ref();
// const timesheet = ref<Timesheet>(default_timesheet);
const is_calendar_limit = computed( ()=>
pay_period.value.pay_year === 2024 &&
pay_period.value.pay_period_no <= 1
@ -29,11 +26,11 @@ export const useTimesheetStore = defineStore('timesheet', () => {
let response;
if (typeof date_or_year === 'string') {
response = await timesheetApprovalService.getPayPeriodByDate(date_or_year);
response = await timesheetService.getPayPeriodByDate(date_or_year);
return true;
}
else if ( typeof date_or_year === 'number' && period_number ) {
response = await timesheetApprovalService.getPayPeriodByYearAndPeriodNumber(date_or_year, period_number);
response = await timesheetService.getPayPeriodByYearAndPeriodNumber(date_or_year, period_number);
return true;
}
else response = default_pay_period;
@ -43,7 +40,7 @@ export const useTimesheetStore = defineStore('timesheet', () => {
} catch(error){
console.error('Could not get current pay period: ', error );
pay_period.value = default_pay_period;
pay_period_employee_overview_list.value = [];
pay_period_overviews.value = [ default_pay_period_overview, ];
//TODO: More in-depth error-handling here
}
@ -51,58 +48,15 @@ export const useTimesheetStore = defineStore('timesheet', () => {
});
};
const getPayPeriodEmployeeOverviewListBySupervisorEmail = async (pay_year: number, period_number: number, supervisor_email: string): Promise<boolean> => {
const getPayPeriodDetailsByEmployeeEmail = async (employee_email: string) => {
return withLoading( is_loading, async () => {
try {
const response = await timesheetApprovalService.getPayPeriodEmployeeOverviewListBySupervisorEmail( pay_year, period_number, supervisor_email );
pay_period_employee_overview_list.value = response.employees_overview;
return true;
} catch (error) {
console.error('There was an error retrieving Employee Pay Period overviews: ', error);
pay_period_employee_overview_list.value = [];
// TODO: More in-depth error-handling here
}
return false;
});
};
const getPayPeriodOverviewByEmployeeEmail = (email: string): PayPeriodEmployeeOverview => {
const response = pay_period_employee_overview_list.value?.find( employee_overview => employee_overview.email === email);
if (typeof response === 'undefined') {
pay_period_employee_overview.value = default_pay_period_employee_overview;
} else {
pay_period_employee_overview.value = response;
}
return pay_period_employee_overview.value;
};
// const getTimesheetByEmail = async (employee_email: string) => {
// return withLoading( is_loading, async () => {
// try{
// const response = await timesheetTempService.getTimesheetsByEmail(employee_email);
// timesheet.value = response;
// return true;
// }catch (error) {
// console.error('There was an error retrieving timesheet details for this employee: ', error);
// timesheet.value = { ...default_timesheet }
// }
// return false;
// });
// };
const getPayPeriodEmployeeDetailsByEmployeeEmail = async (employee_email: string) => {
return withLoading( is_loading, async () => {
try {
const response = await timesheetApprovalService.getPayPeriodEmployeeDetailsByPayPeriodAndEmail(
const response = await timesheetService.getPayPeriodDetailsByPayPeriodAndEmployeeEmail(
pay_period.value.pay_year,
pay_period.value.pay_period_no,
employee_email
);
pay_period_employee_details.value = response;
pay_period_details.value = response;
return true;
} catch (error) {
@ -110,17 +64,33 @@ export const useTimesheetStore = defineStore('timesheet', () => {
// TODO: More in-depth error-handling here
}
pay_period_employee_details.value = default_pay_period_employee_details;
pay_period_details.value = default_pay_period_details;
return false;
});
};
const getTimesheetApprovalCSVReport = async (report_filters?: PayPeriodReportFilters) => {
const getPayPeriodOverviewsBySupervisorEmail = async (pay_year: number, period_number: number, supervisor_email: string): Promise<boolean> => {
return withLoading( is_loading, async () => {
try {
const response = await timesheetApprovalService.getTimesheetApprovalCSVReport(
pay_period.value.pay_year,
pay_period.value.pay_period_no,
const response = await timesheetApprovalService.getPayPeriodOverviewsBySupervisorEmail( pay_year, period_number, supervisor_email );
pay_period_overviews.value = response;
return true;
} catch (error) {
console.error('There was an error retrieving Employee Pay Period overviews: ', error);
pay_period_overviews.value = [ default_pay_period_overview, ];
// TODO: More in-depth error-handling here
}
return false;
});
};
const getPayPeriodReportByYearAndPeriodNumber = async (year: number, period_number: number, report_filters?: TimesheetApprovalCSVReportFilters) => {
return withLoading( is_loading, async () => {
try {
const response = await timesheetApprovalService.getPayPeriodReportByYearAndPeriodNumber(
year,
period_number,
report_filters
);
pay_period_report.value = response;
@ -136,18 +106,15 @@ export const useTimesheetStore = defineStore('timesheet', () => {
};
return {
pay_period,
pay_period_employee_overview_list,
pay_period_employee_overview,
pay_period_employee_details,
timesheet,
is_loading,
is_calendar_limit,
pay_period,
pay_period_overviews,
current_pay_period_overview,
pay_period_details,
getPayPeriodByDateOrYearAndNumber,
// getTimesheetByEmail,
getPayPeriodEmployeeOverviewListBySupervisorEmail,
getPayPeriodOverviewByEmployeeEmail,
getPayPeriodEmployeeDetailsByEmployeeEmail,
getTimesheetApprovalCSVReport,
getPayPeriodOverviewsBySupervisorEmail,
getPayPeriodDetailsByEmployeeEmail,
getPayPeriodReportByYearAndPeriodNumber,
};
});

View File

@ -1,28 +1,41 @@
export const deepEqual = (a: unknown, b: unknown): boolean => {
if (a === b) {
return true;
}
import { unwrapAndClone } from "src/utils/unwrap-and-clone";
if (
a == null || // handles null and undefined
b == null ||
typeof a !== 'object' ||
typeof b !== 'object'
) {
return false;
}
/**
* Internal recursive function comparing two plain values.
*/
const _deepEqualRecursive = (a: unknown, b: unknown): boolean => {
if (a === b) return true;
const aKeys = Object.keys(a as Record<string, unknown>);
const bKeys = Object.keys(b as Record<string, unknown>);
if (a == null || b == null || typeof a !== "object" || typeof b !== "object") {
return false;
}
if (aKeys.length !== bKeys.length) {
return false;
}
// Handle arrays
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return false;
return a.every((val, i) => _deepEqualRecursive(val, b[i]));
} else if (Array.isArray(a) || Array.isArray(b)) {
return false; // one is array, other is not
}
return aKeys.every((key) =>
deepEqual(
(a as Record<string, unknown>)[key],
(b as Record<string, unknown>)[key]
)
);
const aKeys = Object.keys(a as Record<string, unknown>);
const bKeys = Object.keys(b as Record<string, unknown>);
if (aKeys.length !== bKeys.length) return false;
return aKeys.every((key) =>
_deepEqualRecursive(
(a as Record<string, unknown>)[key],
(b as Record<string, unknown>)[key]
)
);
};
/**
* Deep equality check that normalizes reactive objects first.
*/
export const deepEqual = (given: unknown, expected: unknown): boolean => {
const a = unwrapAndClone(given as object);
const b = unwrapAndClone(expected as object);
return _deepEqualRecursive(a, b);
};

View File

@ -0,0 +1,31 @@
export type Normalizer<T> = {
[K in keyof T]: (val: unknown) => T[K];
};
export const normalizeObject = <T>(raw: any, schema: Normalizer<T>): T => {
const result = {} as T;
for (const key in schema) {
result[key] = schema[key](raw[key]);
}
return result;
}
// Example for Expense
// export interface Expense {
// date: string;
// type: "TRAVEL" | "MEAL" | "OTHER";
// comment: string;
// amount?: number;
// mileage?: number;
// }
// const expenseNormalizer: Normalizer<Expense> = {
// date: (v) => String(v ?? ""), // fallback to ""
// type: (v) => (["TRAVEL", "MEAL", "OTHER"].includes(v) ? v : "OTHER"),
// comment: (v) => String(v ?? ""),
// amount: (v) => (typeof v === "number" ? v : undefined),
// mileage: (v) => (typeof v === "number" ? v : undefined),
// };
// export const normalizeExpense = (raw: unknown): Expense =>
// normalizeObject(raw, expenseNormalizer);

View File

@ -1,10 +1,8 @@
import type { Ref } from "vue";
export const withLoading = async <T>( loading_state: Ref<boolean>, fn: () => Promise<T> ) => {
loading_state.value = true;
export const withLoading = async <T>( loading_state: boolean, fn: () => Promise<T> ) => {
loading_state = true;
try {
return await fn();
} finally {
loading_state.value = false;
loading_state = false;
}
};

View File

@ -0,0 +1,6 @@
export const toQSelectOptions = <T>(values: readonly T[], i18n_domain?: string): { label: string; value: T }[] => {
return values.map(value => ({
label: ((i18n_domain ?? "") + value).toString(),
value: value as T
}));
};

View File

@ -0,0 +1,16 @@
import { isProxy, toRaw } from "vue";
/**
* Converts reactive proxies or objects into a deep, plain clone.
*/
export const unwrapAndClone = <T extends object>(obj: T): T => {
const raw = isProxy(obj) ? toRaw(obj) : obj;
// Use structuredClone if available (handles Dates, Maps, Sets, circulars)
if (typeof (globalThis as any).structuredClone === "function") {
return (globalThis as any).structuredClone(raw);
}
// Fallback for older environments (loses Dates, Sets, Maps)
return JSON.parse(JSON.stringify(raw));
};