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"> <script setup lang="ts">
const { title, startDate = "", endDate = "" } = defineProps<{ const { title, startDate = "", endDate = "" } = defineProps<{
title: string; title: string;
startDate: string; startDate?: string;
endDate: string; endDate?: string;
}>(); }>();
const date_format_options = { day: 'numeric', month: 'long', year: 'numeric', }; 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 { useI18n } from 'vue-i18n';
import { useQuasar } from 'quasar'; import { useQuasar } from 'quasar';
import { Chart as ChartJS, Title, Tooltip, Legend, BarElement, CategoryScale, LinearScale, type ChartData, type ChartOptions, type Plugin, type ChartDataset } from 'chart.js'; import { Chart as ChartJS, Title, Tooltip, Legend, BarElement, CategoryScale, LinearScale, type ChartData, type 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 { t } = useI18n();
const $q = useQuasar(); const $q = useQuasar();
@ -16,7 +16,7 @@
ChartJS.defaults.color = $q.dark.isActive ? '#F5F5F5' : '#616161'; ChartJS.defaults.color = $q.dark.isActive ? '#F5F5F5' : '#616161';
const props = defineProps<{ const props = defineProps<{
rawData: PayPeriodEmployeeDetails | undefined; rawData: TimesheetDetails | undefined;
options?: ChartOptions<"bar"> | undefined; options?: ChartOptions<"bar"> | undefined;
plugins?: Plugin<"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 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 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 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 { PayPeriodOverview } from 'src/modules/timesheet-approval/models/pay-period-overview.models';
import type { TimesheetDetails } from 'src/modules/timesheets/models/timesheet.models'; import type { PayPeriodDetails } from 'src/modules/timesheets/models/pay-period-details.models';
const dialog_model = defineModel<boolean>('dialog', { default: false }); const dialog_model = defineModel<boolean>('dialog', { default: false });
defineProps<{ defineProps<{
isLoading: boolean; isLoading: boolean;
employeeOverview: TimesheetApprovalOverviewCrewMember; payPeriodOverview: PayPeriodOverview;
timesheetDetails: TimesheetDetails; payPeriodDetails: PayPeriodDetails;
}>(); }>();
// const timesheet_store = useTimesheetStore(); // const timesheet_store = useTimesheetStore();
@ -52,7 +52,7 @@
v-if="!isLoading" v-if="!isLoading"
class="text-h5 text-weight-bolder text-center text-primary q-pa-none text-uppercase col-auto" class="text-h5 text-weight-bolder text-center text-primary q-pa-none text-uppercase col-auto"
> >
<span> {{ timesheetDetails.employee_full_name }} </span> <span> {{ payPeriodDetails.employee_full_name }} </span>
<q-separator <q-separator
spaced spaced
@ -114,7 +114,7 @@
style="min-height: 300px;" style="min-height: 300px;"
> >
<DetailedDialogChartHoursWorked <DetailedDialogChartHoursWorked
:raw-data="timesheetDetails" :raw-data="payPeriodDetails"
class="col-7" class="col-7"
/> />
@ -125,7 +125,7 @@
<div class="column col justify-center no-wrap q-pa-none"> <div class="column col justify-center no-wrap q-pa-none">
<DetailedDialogChartShiftTypes <DetailedDialogChartShiftTypes
:raw-data="employeeOverview" :raw-data="payPeriodOverview"
class="col-5" class="col-5"
/> />
@ -135,7 +135,7 @@
/> />
<DetailedDialogChartExpenses <DetailedDialogChartExpenses
:raw-data="timesheetDetails" :raw-data="payPeriodDetails"
class="col" class="col"
/> />
</div> </div>

View File

@ -1,17 +1,16 @@
import { useTimesheetStore } from "src/stores/timesheet-store"; import { useTimesheetStore } from "src/stores/timesheet-store";
import { useAuthStore } from "src/stores/auth-store"; import { useAuthStore } from "src/stores/auth-store";
import type { TimesheetApprovalCSVReportFilters } from "src/modules/timesheet-approval/models/timesheet-approval-csv-report.models"; import type { TimesheetApprovalCSVReportFilters } from "src/modules/timesheet-approval/models/timesheet-approval-csv-report.models";
import { date } from "quasar";
export const useTimesheetApprovalApi = () => { export const useTimesheetApprovalApi = () => {
const timesheet_store = useTimesheetStore(); const timesheet_store = useTimesheetStore();
const auth_store = useAuthStore(); const auth_store = useAuthStore();
const getPayPeriodOverviewByDate = async (date_string: string): Promise<void> => { const getPayPeriodOverviewsByDate = async (date_string: string): Promise<void> => {
const success = await timesheet_store.getPayPeriodByDateOrYearAndNumber(date_string); const success = await timesheet_store.getPayPeriodByDateOrYearAndNumber(date_string);
if (success) { if (success) {
await timesheet_store.getPayPeriodEmployeeOverviewListBySupervisorEmail( await timesheet_store.getPayPeriodOverviewsBySupervisorEmail(
timesheet_store.pay_period.pay_year, timesheet_store.pay_period.pay_year,
timesheet_store.pay_period.pay_period_no, timesheet_store.pay_period.pay_period_no,
auth_store.user.email auth_store.user.email
@ -19,35 +18,7 @@ export const useTimesheetApprovalApi = () => {
} }
}; };
/* This method attempts to get the next or previous pay period. const getTimesheetApprovalCSVReport = async ( report_filter_company: boolean[], report_filter_type: boolean[], year?: number, period_number?: number ) => {
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 [ targo, solucom ] = report_filter_company; const [ targo, solucom ] = report_filter_company;
const [ shifts, expenses, holiday, vacation ] = report_filter_type; const [ shifts, expenses, holiday, vacation ] = report_filter_type;
const options = { const options = {
@ -55,12 +26,15 @@ export const useTimesheetApprovalApi = () => {
companies: { targo, solucom }, companies: { targo, solucom },
} as TimesheetApprovalCSVReportFilters; } 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 { return {
getPayPeriodOverviewByDate, getPayPeriodOverviewsByDate,
getNextOrPreviousPayPeriodOverviewList,
getTimesheetApprovalCSVReport, 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 { api } from "src/boot/axios";
import type { PayPeriodOverview } from "src/modules/timesheet-approval/types/pay-period-overview"; import type { TimesheetApprovalCSVReportFilters } from "src/modules/timesheet-approval/models/timesheet-approval-csv-report.models";
import type { PayPeriod } from "src/modules/shared/types/pay-period-interface"; import type { PayPeriodOverview } from "src/modules/timesheet-approval/models/pay-period-overview.models";
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";
export const timesheetApprovalService = { export const timesheetApprovalService = {
getPayPeriodByDate: async (date_string: string): Promise<PayPeriod> => { getPayPeriodOverviewsBySupervisorEmail: async (year: number, period_number: number, supervisor_email: string): Promise<PayPeriodOverview[]> => {
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
const response = await api.get(`pay-periods/${year}/${period_number}/${supervisor_email}`); const response = await api.get(`pay-periods/${year}/${period_number}/${supervisor_email}`);
return response.data; return response.data;
}, },
getPayPeriodEmployeeDetailsByPayPeriodAndEmail: async (year: number, period_no: number, email: string): Promise<PayPeriodEmployeeDetails> => { getPayPeriodReportByYearAndPeriodNumber: async (year: number, period_number: number, report_filters?: TimesheetApprovalCSVReportFilters) => {
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, }}); const response = await api.get(`csv/${year}/${period_number}`, { params: { report_filters, }});
return response.data; return response.data;
}, },

View File

@ -1,15 +1,16 @@
<script setup lang="ts"> <script
import type { TimesheetExpense } from '../../types/expense.interfaces'; setup
import type { ExpenseType } from '../../types/expense.types'; lang="ts"
/* eslint-disable */ >
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 files = defineModel<File[] | null>('files');
const datePickerOpen = defineModel<boolean | null>('datePickerOpen', {default: false }); const is_navigator_open = ref(false);
//------------------ props ------------------ //------------------ props ------------------
const props = defineProps<{ defineProps<{
type_options: { label: string; value: ExpenseType }[]; type_options: { label: string; value: ExpenseType }[];
show_amount: boolean; show_amount: boolean;
is_readonly: boolean; is_readonly: boolean;
@ -26,9 +27,8 @@ const props = defineProps<{
//------------------ emits ------------------ //------------------ emits ------------------
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'submit'): void; 'submit': [void];
}>(); }>();
</script> </script>
<template> <template>
@ -83,6 +83,7 @@ const emit = defineEmits<{
map-options map-options
:label="$t('timesheet.expense.type')" :label="$t('timesheet.expense.type')"
:rules="[rules.typeRequired]" :rules="[rules.typeRequired]"
:option-label="label => $t(label)"
@update:model-value="val => setType(val as ExpenseType)" @update:model-value="val => setType(val as ExpenseType)"
/> />
@ -163,7 +164,6 @@ const emit = defineEmits<{
name="attach_file" name="attach_file"
size="sm" size="sm"
color="primary" color="primary"
/> />
</template> </template>
</q-file> </q-file>

View File

@ -1,7 +1,10 @@
<script setup lang="ts"> <script
setup
lang="ts"
>
import { ref } from 'vue'; import { ref } from 'vue';
/* eslint-disable */
const props = defineProps<{ const { commentString } = defineProps<{
commentString: string; commentString: string;
}>(); }>();
@ -10,7 +13,7 @@ const emit = defineEmits<{
clickSave: [comment: string]; clickSave: [comment: string];
}>(); }>();
const text = ref(props.commentString); const text = ref(commentString);
const close = () => { const close = () => {
emit('clickClose'); emit('clickClose');

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'; const { date_iso, mode, current_shift, is_open, close } = useShiftStore();
import { useI18n } from 'vue-i18n'; const { upsertOrDeleteShiftByEmployeeEmail } = useShiftApi();
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 { t } = useI18n(); const { employeeEmail } = defineProps<{
employeeEmail: string;
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 isSubmitting = ref(false);
const errorBanner = ref<string | null>(null); const errorBanner = ref<string | null>(null);
const conflicts = ref<Array<{ start_time: string; end_time: string; type: string }>>([]); 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(() => const canSubmit = computed(() =>
props.mode === 'delete' || mode === 'delete' ||
(startTime.value.trim().length === 5 && (current_shift.start_time.trim().length === 5 &&
endTime.value.trim().length === 5 && current_shift.end_time.trim().length === 5 &&
isShiftKey(type.value)) current_shift.type !== undefined)
); );
watch(
()=> [opened.value, props.mode, props.initialShift, props.dateIso],
()=> { if (opened.value) hydrateFromProps();},
{ immediate: true }
);
</script> </script>
<!-- create/edit/delete shifts dialog -->
<template> <template>
<q-dialog v-model="opened" <q-dialog
v-model=" is_open"
persistent persistent
transition-show="fade" transition-show="fade"
transition-hide="fade"> transition-hide="fade"
>
<q-card class="q-pa-md"> <q-card class="q-pa-md">
<div class="row items-center q-mb-sm"> <div class="row items-center q-mb-sm">
<q-icon name="schedule" <q-icon
name="schedule"
size="24px" size="24px"
class="q-mr-sm"/> class="q-mr-sm"
/>
<div class="text-h6"> <div class="text-h6">
{{ {{
props.mode === 'create' mode === 'create'
? $t('timesheet.shift.actions.add') ? $t('timesheet.shift.actions.add')
: props.mode === 'edit' : mode === 'update'
? $t('timesheet.shift.actions.edit') ? $t('timesheet.shift.actions.edit')
: $t('timesheet.shift.actions.delete') : $t('timesheet.shift.actions.delete')
}} }}
</div> </div>
<q-space /> <q-space />
<q-badge outline color="primary"> <q-badge
{{ props.dateIso }} outline
color="primary"
>
{{ date_iso }}
</q-badge> </q-badge>
</div> </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="row ">
<div class="col"> <div class="col">
<q-input <q-input
v-model="startTime" v-model="current_shift.start_time"
:label="$t('timesheet.shift.fields.start')" :label="$t('timesheet.shift.fields.start')"
filled dense filled
dense
inputmode="numeric" inputmode="numeric"
mask="##:##" mask="##:##"
/> />
</div> </div>
<div class="col"> <div class="col">
<q-input <q-input
v-model="endTime" v-model="current_shift.end_time"
:label="$t('timesheet.shift.fields.end')" :label="$t('timesheet.shift.fields.end')"
filled dense filled
dense
inputmode="numeric" inputmode="numeric"
mask="##:##" mask="##:##"
/> />
@ -174,41 +89,61 @@ watch(
</div> </div>
<div class="row items-center"> <div class="row items-center">
<q-select <q-select
v-model="type" v-model="current_shift.type"
options-dense options-dense
:options="props.shiftOptions" :options="SHIFT_TYPES"
:label="$t('timesheet.shift.types.label')" :label="$t('timesheet.shift.types.label')"
class="col" class="col"
color="primary" color="primary"
filled dense filled
dense
hide-dropdown-icon hide-dropdown-icon
emit-value emit-value
map-options map-options
/> />
<q-toggle <q-toggle
v-model="isRemote" v-model="current_shift.is_remote"
:label="$t('timesheet.shift.types.REMOTE')" :label="$t('timesheet.shift.types.REMOTE')"
class="col-auto" /> class="col-auto"
/>
</div> </div>
<q-input <q-input
v-model="comment" v-model="current_shift.comment"
type="textarea" type="textarea"
autogrow filled dense autogrow
filled
dense
:label="$t('timesheet.shift.fields.header_comment')" :label="$t('timesheet.shift.fields.header_comment')"
:counter="true" :maxlength="512" :counter="true"
:maxlength="512"
/> />
</div> </div>
<div v-else class="q-pa-md"> <div
v-else
class="q-pa-md"
>
{{ $t('timesheet.shift.actions.delete_confirmation_msg') }} {{ $t('timesheet.shift.actions.delete_confirmation_msg') }}
</div> </div>
<div v-if="errorBanner" class="q-mt-md"> <div
<q-banner dense class="bg-red-2 text-negative">{{ errorBanner }}</q-banner> v-if="errorBanner"
<div v-if="conflicts.length" class="q-mt-xs"> 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> <div class="text-caption">Conflits :</div>
<ul class="q-pl-md q-mt-xs"> <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 }}) {{ c.start_time }}{{ c.end_time }} ({{ c.type }})
</li> </li>
</ul> </ul>
@ -222,24 +157,16 @@ watch(
flat flat
color="grey-8" color="grey-8"
:label="$t('timesheet.cancel_button')" :label="$t('timesheet.cancel_button')"
@click="() => { opened = false; emit('close');}" @click="close"
/> />
<q-btn <q-btn
v-if="props.mode === 'delete'"
outline color="negative"
icon="cancel"
:label="$t('timesheet.delete_button')"
:loading="isSubmitting"
:disable="!canSubmit"
@click="onSubmit"
/>
<q-btn v-else
color="primary" color="primary"
icon="save_alt" icon="save_alt"
:label="$t('timesheet.save_button')" :label="mode === 'delete' ? $t('timesheet.delete_button') : $t('timesheet.save_button')"
:loading="isSubmitting" :loading="isSubmitting"
:disable="!canSubmit" :disable="!canSubmit"
@click="onSubmit"/> @click="upsertOrDeleteShiftByEmployeeEmail(employeeEmail)"
/>
</div> </div>
</q-card> </q-card>
</q-dialog> </q-dialog>

View File

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

View File

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

View File

@ -1,46 +1,49 @@
<script setup lang="ts"> <script
import type { PayPeriod } from 'src/modules/shared/types/pay-period-interface'; setup
import detailedShiftListHeader from './detailed-shift-list-header.vue'; lang="ts"
import detailedShiftListRow from './detailed-shift-list-row.vue'; >
import { date } from 'quasar'; import { date } from 'quasar';
import type { TimesheetPayPeriodDetailsOverview } from '../../types/timesheet.interfaces'; import ShiftListHeader from 'src/modules/timesheets/components/shift/shift-list-header.vue';
import type { Shift } from '../../types/shift.interfaces'; import ShiftListRow from 'src/modules/timesheets/components/shift/shift-list-row.vue';
import { default_shift } from '../../types/shift.defaults'; 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<{ const props = defineProps<{
rawData: TimesheetPayPeriodDetailsOverview; rawData: PayPeriodDetails;
currentPayPeriod: PayPeriod; currentPayPeriod: PayPeriod;
}>(); }>();
const emit = defineEmits<{ const timesheet_api = useTimesheetApi();
'request-add' : [payload: { date: string }]; const { openCreate, openDelete, openUpdate } = useShiftStore();
'request-edit' : [payload: { date: string; shift: Shift }];
'request-delete' : [payload: { date: string; shift: Shift }];
// 'save-comment' : [payload: { date: string; shift: Shift; comment: string }];
}>();
const get_date_from_short = (short_date: string): const get_date_from_short = (short_date: string): Date => {
Date => new Date(props.currentPayPeriod.pay_year.toString() + '/' + short_date); return new Date(props.currentPayPeriod.pay_year.toString() + '/' + short_date);
const to_iso_date = (short_date: string): };
string => date.formatDate(get_date_from_short(short_date), 'YYYY-MM-DD');
const shifts_or_placeholder = (shifts: Shift[]): const to_iso_date = (short_date: string): string => {
Shift[] => { return shifts.length > 0 ? shifts : [default_shift]; }; 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 => { const getDate = (shift_date: string): Date => {
return new Date(props.currentPayPeriod.pay_year.toString() + '/' + shift_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> </script>
<template> <template>
<!-- shift's colored legend -->
<ShiftListLegend :is-loading="false" />
<div <div
v-for="week, index in props.rawData" v-for="week, index in props.rawData.weeks"
:key="index" :key="index"
class="q-px-xs q-pt-xs rounded-5 col" class="q-px-xs q-pt-xs rounded-5 col"
> >
@ -51,11 +54,10 @@ import { default_shift } from '../../types/shift.defaults';
bordered bordered
class="row items-center rounded-10 q-mb-xs" class="row items-center rounded-10 q-mb-xs"
> >
<!-- Dates column --> <!-- Dates column -->
<q-card-section class="col-auto q-pa-xs text-white"> <q-card-section class="col-auto q-pa-xs text-white">
<div <div class="bg-primary rounded-10 q-pa-xs text-center">
class="bg-primary rounded-10 q-pa-xs text-center"
>
<q-item-label <q-item-label
style="font-size: 0.7em;" style="font-size: 0.7em;"
class="text-uppercase" class="text-uppercase"
@ -73,13 +75,13 @@ import { default_shift } from '../../types/shift.defaults';
<!-- List of shifts column --> <!-- List of shifts column -->
<q-card-section class="col q-pa-none"> <q-card-section class="col q-pa-none">
<detailedShiftListHeader /> <ShiftListHeader />
<detailedShiftListRow <ShiftListRow
v-for="shift, shift_index in shifts_or_placeholder(day.shifts)" v-for="shift, shift_index in shifts_or_placeholder(day.shifts)"
:key="shift_index" :key="shift_index"
:shift="shift" :shift="shift"
@request-edit=" ({ shift }) => on_request_edit(to_iso_date(day.short_date), shift )" @request-update="value => openUpdate(to_iso_date(day.short_date), value)"
@request-delete="({ shift }) => on_request_delete(to_iso_date(day.short_date), shift )" @request-delete="value => openDelete(to_iso_date(day.short_date), value)"
/> />
</q-card-section> </q-card-section>
<!-- add shift btn column --> <!-- add shift btn column -->
@ -89,7 +91,7 @@ import { default_shift } from '../../types/shift.defaults';
color="primary" color="primary"
icon="more_time" icon="more_time"
class="q-pa-sm" 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-section>
</q-card> </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 { useTimesheetStore } from "src/stores/timesheet-store";
import { isProxy, toRaw } from "vue"; import { useExpenseItems } from "src/modules/timesheets/composables/use-expense-items";
import { normalizeExpense, validateExpenseUI } from "../../utils/expenses-validators"; import { normalizeExpense, validateExpenseUI } from "../../utils/expenses-validators";
import type { ExpenseType } from "../../types/expense.types"; import type { ExpensesApiError } from "src/modules/timesheets/models/expense.validation";
import { ExpensesApiError } from "../../types/expense-validation.interface"; import type { Expense, ExpenseType, PayPeriodExpenses } from "src/modules/timesheets/models/expense.models";
import type {
ExpensePayload,
PayPeriodExpenses,
TimesheetExpense,
UpsertExpensesBody,
UpsertExpensesResponse
} from "../../types/expense.interfaces";
/* eslint-disable */ const { pay_period } = useTimesheetStore();
const toPlain = <T extends object>(obj:T): T => { const expense_items = useExpenseItems(draft);
const raw = isProxy(obj) ? toRaw(obj) : obj;
if( typeof (globalThis as any).structuredClone === 'function') {
return (globalThis as any).structuredClone(raw);
}
return JSON.parse(JSON.stringify(raw));
};
const normalizePayload = (expense: ExpensePayload): ExpensePayload => { //PUT by employee_email, year and period no
const exp = normalizeExpense(expense as unknown as TimesheetExpense); export const putPayPeriodExpensesByEmployeeEmail = async (employee_email: string, expenses: Expense[]): Promise<PayPeriodExpenses> => {
const out: ExpensePayload = { const encoded_email = encodeURIComponent(employee_email);
date: exp.date, const encoded_year = encodeURIComponent(String(pay_period.pay_year));
type: exp.type as ExpenseType, const encoded_pay_period_no = encodeURIComponent(String(pay_period.pay_period_no));
comment: exp.comment || '',
};
if(typeof exp.amount === 'number') out.amount = exp.amount;
if(typeof exp.mileage === 'number') out.mileage = exp.mileage;
return out;
}
//GET by email, year and period no const flat_expenses = expenses.map(expenses): [];
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));
try { const normalized: Expense[] = plain.map((exp) => {
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 norm = normalizeExpense(exp as TimesheetExpense); const norm = normalizeExpense(exp as TimesheetExpense);
validateExpenseUI(norm, 'expense_item'); validateExpenseUI(norm, 'expense_item');
return normalizePayload(norm as unknown as ExpensePayload); return normalizePayload(norm as unknown as ExpensePayload);
@ -85,10 +25,10 @@ export const putPayPeriodExpenses = async (
try { try {
const { data } = await api.put<UpsertExpensesResponse>( const { data } = await api.put<UpsertExpensesResponse>(
`/expenses/${encoded_email}/${encoded_year}/${encoded_pay_period_no}`, // `/expenses/${encoded_email}/${encoded_year}/${encoded_pay_period_no}`,
body, // body,
{ headers: {'Content-Type': 'application/json'}} // { headers: {'Content-Type': 'application/json'}}
); // );
const items = Array.isArray(data?.data?.expenses) const items = Array.isArray(data?.data?.expenses)
? data.data.expenses.map(normalizeExpense) ? data.data.expenses.map(normalizeExpense)
@ -97,7 +37,7 @@ export const putPayPeriodExpenses = async (
...(data?.data ?? { ...(data?.data ?? {
pay_period_no, pay_period_no,
pay_year, pay_year,
employee_email: email, employee_email: employee_email,
is_approved: false, is_approved: false,
expenses: [], expenses: [],
totals: {amount: 0, mileage: 0}, totals: {amount: 0, mileage: 0},
@ -117,12 +57,12 @@ export const putPayPeriodExpenses = async (
}; };
export const postPayPeriodExpenses = async ( export const postPayPeriodExpenses = async (
email: string, employee_email: string,
pay_year: number, pay_year: number,
pay_period_no: number, pay_period_no: number,
new_expenses: TimesheetExpense[] new_expenses: TimesheetExpense[]
): Promise<PayPeriodExpenses> => { ): Promise<PayPeriodExpenses> => {
const encoded_email = encodeURIComponent(email); const encoded_email = encodeURIComponent(employee_email);
const encoded_year = encodeURIComponent(String(pay_year)); const encoded_year = encodeURIComponent(String(pay_year));
const encoded_pp = encodeURIComponent(String(pay_period_no)); const encoded_pp = encodeURIComponent(String(pay_period_no));
@ -148,7 +88,7 @@ export const postPayPeriodExpenses = async (
...(data?.data ?? { ...(data?.data ?? {
pay_period_no, pay_period_no,
pay_year, pay_year,
employee_email: email, employee_email: employee_email,
is_approved: false, is_approved: false,
expenses: [], expenses: [],
totals: { amount: 0, mileage: 0 }, totals: { amount: 0, mileage: 0 },

View File

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

View File

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

View File

@ -1,52 +1,55 @@
import { ref, type Ref } from "vue"; import { ref, type Ref } from "vue";
import { normalizeExpense, validateExpenseUI } from "../utils/expenses-validators"; import { normalizeExpense, validateExpenseUI } from "../utils/expenses-validators";
import { normExpenseType } from "../utils/expense.util"; import { normExpenseType } from "../utils/expense.util";
import type { TimesheetExpense } from "../types/expense.interfaces"; import type { Expense, PayPeriodExpenses } from "src/modules/timesheets/models/expense.models";
import { useExpensesStore } from "src/stores/expense-store";
import { unwrapAndClone } from "src/utils/unwrap-and-clone";
type UseExpenseItemsParams = { const expenses_store = useExpensesStore();
initial_expenses?: TimesheetExpense[] | null | undefined;
draft: Ref<Partial<TimesheetExpense>>; export const useExpenseItems = () => {
is_approved: Ref<boolean> | boolean; 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 || '',
}; };
export const useExpenseItems = ({ if(typeof exp.amount === 'number') out.amount = exp.amount;
initial_expenses, if(typeof exp.mileage === 'number') out.mileage = exp.mileage;
draft, return out;
is_approved }
}: UseExpenseItemsParams) => {
const items = ref<TimesheetExpense[]>(
Array.isArray(initial_expenses) ? initial_expenses.map(normalizeExpense) : []
);
const addFromDraft = () => { const addFromDraft = () => {
const candidate: TimesheetExpense = normalizeExpense({ const candidate: Expense = normalizeExpense({
date: draft.value.date, date: draft.date,
type: normExpenseType(draft.value.type), type: normExpenseType(draft.type),
...(typeof draft.value.amount === 'number' ? { amount: draft.value.amount }: {}), ...(typeof draft.amount === 'number' ? { amount: draft.amount }: {}),
...(typeof draft.value.mileage === 'number' ? { mileage: draft.value.mileage }: {}), ...(typeof draft.mileage === 'number' ? { mileage: draft.mileage }: {}),
comment: String(draft.value.comment ?? '').trim(), comment: String(draft.comment ?? '').trim(),
} as TimesheetExpense); } as Expense);
validateExpenseUI(candidate, 'expense_draft'); validateExpenseUI(candidate, 'expense_draft');
items.value = [ ...items.value, candidate]; expenses = [ ...expenses, candidate];
}; };
const removeAt = (index: number) => { const removeAt = (index: number) => {
const locked = typeof is_approved === 'boolean' ? is_approved : is_approved.value; if(index < 0 || index >= expenses.length) return;
if(locked) return; expenses = expenses.filter((_,i)=> i !== index);
if(index < 0 || index >= items.value.length) return;
items.value = items.value.filter((_,i)=> i !== index);
}; };
const validateAll = () => { const validateAll = () => {
for (const expense of items.value) { for (const expense of expenses) {
validateExpenseUI(expense, 'expense_item'); validateExpenseUI(expense, 'expense_item');
} }
}; };
const payload = () => items.value.map(normalizeExpense); const payload = () => expenses.map(normalizeExpense);
return { return {
items, expenses,
addFromDraft, addFromDraft,
removeAt, removeAt,
validateAll, 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 ExpenseType = 'PER_DIEM' | 'MILEAGE' | 'EXPENSES' | 'PRIME_GARDE';
export const EXPENSE_TYPE: ExpenseType[] = ['PER_DIEM', 'MILEAGE', 'EXPENSES', 'PRIME_GARDE',];
export const TYPES_WITH_MILEAGE_ONLY: Readonly<ExpenseType[]> = ['MILEAGE'];
export const TYPES_WITH_AMOUNT_ONLY: Readonly<ExpenseType[]> = ['PER_DIEM', 'EXPENSES', 'PRIME_GARDE',];
export interface Expense {
date: string;
type: ExpenseType;
amount?: number;
mileage?: number;
comment: string;
supervisor_comment?: string;
is_approved: boolean;
}
export type ExpenseTotals = { export type ExpenseTotals = {
amount: number; amount: number;
mileage: number; mileage: number;
reimburseable_total?: number;
}; };
// export type ExpenseSavePayload = { export interface PayPeriodExpenses {
// pay_period_no: number;
// pay_year: number;
// email: string;
// expenses: TimesheetExpense[];
// };
export interface Expense {
// is_approved: boolean;
// comment: string;
// amount: number;
// supervisor_comment: string;
// }
// export interface TimesheetExpense {
date: string;
type: string;
amount?: number;
mileage?: number;
comment?: string;
supervisor_comment?: string;
is_approved?: boolean;
}
// export interface PayPeriodExpenses {
export interface TimesheetExpenses {
pay_period_no: number;
pay_year: number;
employee_email: string;
is_approved: boolean; is_approved: boolean;
// expenses: TimesheetExpense[];
expenses: Expense[]; expenses: Expense[];
totals?: { totals?: ExpenseTotals;
amount: number;
mileage: number;
reimbursable_total?: number;
}
} }
// export interface ExpensePayload{ export interface TimesheetDetailsWeekDayExpenses {
// date: string; cash: Expense[];
// type: ExpenseType; km: Expense[];
// amount?: number; [otherType: string]: Expense[];
// mileage?: number; }
// comment: string;
// }
// export interface UpsertExpensesBody { export const default_expense: Expense = {
// expenses: ExpensePayload[]; date: '',
// } type: 'EXPENSES',
amount: 0,
comment: '',
is_approved: false,
};
// export interface UpsertExpensesResponse { export const default_pay_period_expenses: PayPeriodExpenses = {
// data: 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,76 +1,43 @@
// export const SHIFT_KEY = [ export const SHIFT_TYPES = [
// 'REGULAR', 'REGULAR',
// 'EVENING', 'EVENING',
// 'EMERGENCY', 'EMERGENCY',
// 'HOLIDAY', 'OVERTIME',
// 'VACATION', 'HOLIDAY',
// 'SICK' 'VACATION',
// ] as const; '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 UpsertAction = 'create' | 'update' | 'delete';
// 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 ShiftLegendItem = { export type ShiftLegendItem = {
type: ShiftKey; type: ShiftType;
color: string; color: string;
label_key: string; label_type: string;
text_color?: string; text_color?: string;
}; };
export interface Shift { export interface Shift {
date: string; date: string;
type: ShiftKey; type: ShiftType;
start_time: string; start_time: string;
end_time: string; end_time: string;
comment: string; comment: string | undefined;
is_approved: boolean; is_approved: boolean;
is_remote: boolean; is_remote: boolean;
} }
export interface UpsertShiftsResponse { export interface UpsertShiftsResponse {
action: UpsertAction; action: UpsertAction;
// day: DayShift[];
day: Shift[]; day: Shift[];
} }
// export interface CreateShiftPayload { export interface UpsertShift {
// date: string; old_shift?: Shift | undefined;
// type: ShiftKey; new_shift?: Shift | undefined;
// 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 const default_shift: Readonly<Shift> = { export const default_shift: Readonly<Shift> = {
date: '', date: '',

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 = { export type PayPeriodLabel = {
start_date: string; start_date: string;
end_date: string; end_date: string;

View File

@ -1,182 +1,48 @@
<script setup lang="ts"> <script
setup
lang="ts"
>
import { date } from 'quasar'; import { date } from 'quasar';
import { computed, onMounted } from 'vue'; import { computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useAuthStore } from 'src/stores/auth-store'; 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 { useTimesheetStore } from 'src/stores/timesheet-store';
import { useTimesheetApi } from '../composables/api/use-timesheet-api'; import { useTimesheetApi } from '../composables/api/use-timesheet-api';
import { buildShiftOptions } from '../utils/shift.util';
import { formatPayPeriodLabel } from '../utils/timesheet-format.util'; import { formatPayPeriodLabel } from '../utils/timesheet-format.util';
import TimesheetNavigation from '../components/timesheet/timesheet-navigation.vue'; import PageHeaderTemplate from 'src/modules/shared/components/page-header-template.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 */
//------------------- stores ------------------- //------------------- stores -------------------
const { locale, t } = useI18n(); const { locale } = useI18n();
const auth_store = useAuthStore(); const auth_store = useAuthStore();
const expenses_store = useExpensesStore();
const shift_store = useShiftStore();
const timesheet_store = useTimesheetStore();
const timesheet_api = useTimesheetApi(); 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,
});
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 ------------------- //------------------- pay-period format label -------------------
const date_options: Intl.DateTimeFormatOptions = { day: 'numeric', month: 'long', year: 'numeric' }; const date_options: Intl.DateTimeFormatOptions = { day: 'numeric', month: 'long', year: 'numeric' };
const pay_period_label = computed(() => formatPayPeriodLabel( const pay_period_label = computed(() => formatPayPeriodLabel(
timesheet_store.pay_period.label, pay_period.label,
locale.value, locale.value,
date.extractDate, date.extractDate,
date_options date_options
) )
); );
//------------------- q-select Shift options -------------------
const shift_options = computed(() => buildShiftOptions(SHIFT_KEY, t));
onMounted(async () => { onMounted(async () => {
await timesheet_store.loadToday(auth_store.user.email); await timesheet_api.getPayPeriodDetailsByDate(date.formatDate(new Date(), 'YYYY-MM-DD'));
}); });
// ------------------- shifts -------------------
const onRequestAdd = ({ date }: { date: string }) => shift_store.openCreate(date);
const onRequestEdit = ({ date, shift }: { date: string; shift: any }) => shift_store.openEdit(date, shift);
const onRequestDelete = async ({ date, shift }: { date: string; shift: any }) => shift_store.openDelete(date, shift);
const onShiftSaved = async () => {
await timesheet_store.refreshCurrentPeriodForUser(auth_store.user.email);
};
</script> </script>
<template> <template>
<q-page padding class="q-pa-md bg-secondary" > <q-page
<!-- title and dates --> padding
<div class="text-h4 row justify-center text-center q-mt-lg text-uppercase text-weight-bolder text-grey-8"> class="q-pa-md bg-secondary"
{{ $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 }} <PageHeaderTemplate
</div> :title="$t('timesheet.title')"
<div :start-date="pay_period_label.start_date"
class="text-grey-8 text-uppercase q-mx-md" :end-date="pay_period_label.end_date"
: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> </q-page>
</template> </template>

View File

@ -1,23 +1,14 @@
import { api } from "src/boot/axios"; import { api } from "src/boot/axios";
import type { PayPeriod } from "src/modules/shared/types/pay-period-interface"; import type { UpsertShift } from "src/modules/timesheets/models/shift.models";
import type { PayPeriodEmployeeDetails } from "src/modules/timesheet-approval/types/pay-period-employee-details"; import type { PayPeriod } from "src/modules/shared/models/pay-period.models";
import type { PayPeriodOverview } from "src/modules/timesheet-approval/types/pay-period-overview"; import type { PayPeriodDetails } from "src/modules/timesheets/models/pay-period-details.models";
import type { PayPeriodReportFilters } from "src/modules/timesheet-approval/types/pay-period-report"; import type { PayPeriodOverview } from "src/modules/timesheet-approval/models/pay-period-overview.models";
import type { Timesheet } from "../types/timesheet.interfaces"; import type { PayPeriodExpenses } from "src/modules/timesheets/models/expense.models";
import type { CreateShiftPayload, CreateWeekShiftPayload } from "../types/shift.interfaces";
export const timesheetService = { export const timesheetService = {
//GET getPayPeriodDetailsByEmployeeEmail: async (email: string): Promise<PayPeriodDetails> => {
getTimesheetsByEmail: async ( email: string, offset = 0): Promise<Timesheet> => { const response = await api.get(`/timesheets/${encodeURIComponent(email)}`);
const response = await api.get(`/timesheets/${encodeURIComponent(email)}`, {params: offset ? { offset } : undefined}); return response.data;
return response.data as Timesheet;
},
//POST
createTimesheetShifts: async ( email: string, shifts: CreateShiftPayload[], offset = 0): Promise<Timesheet> => {
const payload: CreateWeekShiftPayload = { shifts };
const response = await api.post(`/timesheets/shifts/${encodeURIComponent(email)}`, payload, { params: offset ? { offset }: undefined });
return response.data as Timesheet;
}, },
getPayPeriodByDate: async (date_string: string): Promise<PayPeriod> => { getPayPeriodByDate: async (date_string: string): Promise<PayPeriod> => {
@ -30,22 +21,18 @@ export const timesheetService = {
return response.data; return response.data;
}, },
getPayPeriodOverviewsBySupervisorEmail: async (year: number, period_number: number, supervisor_email: string): Promise<PayPeriodOverview[]> => {
getPayPeriodEmployeeOverviews: async (year: number, period_number: number, supervisor_email: string): Promise<PayPeriodOverview> => {
// TODO: REMOVE MOCK DATA PEFORE PUSHING TO PROD
const response = await api.get(`pay-periods/${year}/${period_number}/${supervisor_email}`); const response = await api.get(`pay-periods/${year}/${period_number}/${supervisor_email}`);
console.log('pay period data: ', response.data);
return response.data; return response.data;
}, },
getTimesheetsByPayPeriodAndEmail: async (year: number, period_no: number, email: string): Promise<PayPeriodEmployeeDetails> => { getPayPeriodDetailsByPayPeriodAndEmployeeEmail: async (year: number, period_no: number, email: string): Promise<PayPeriodDetails> => {
const response = await api.get('timesheets', { params: { year, period_no, email, } }); const response = await api.get('timesheets', { params: { year, period_no, email, } });
console.log('employee details: ', response.data);
return response.data; return response.data;
}, },
getTimesheetApprovalCSVReport: async (year: number, period_number: number, report_filters?: PayPeriodReportFilters) => { upsertOrDeletePayPeriodDetailsByDateAndEmployeeEmail: async (email: string, payload: UpsertShift[] | PayPeriodExpenses, pay_period: PayPeriod, date?: string): Promise<PayPeriodDetails> => {
const response = await api.get(`csv/${year}/${period_number}`, { params: { report_filters, }}); if (date) return (await api.put(`/shifts/upsert/${email}/${date}`, payload)).data;
return response.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 { Expense, ExpenseTotals, ExpenseType, PayPeriodExpenses } from "src/modules/timesheets/models/expense.models";
import type { ExpenseSavePayload, ExpenseTotals, ExpenseType } from "../types/expense.types";
/* eslint-disable */
//------------------ normalization / icons ------------------ //------------------ normalization / icons ------------------
export const normExpenseType = (type: unknown): string => export const normExpenseType = (type: unknown): string =>
String(type ?? '').trim().toUpperCase(); 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 ------------------ //------------------ totals ------------------
export const computeExpenseTotals = (items: readonly TimesheetExpense[]): ExpenseTotals => export const computeExpenseTotals = (items: readonly Expense[]): ExpenseTotals =>
items.reduce<ExpenseTotals>( items.reduce<ExpenseTotals>(
(acc, e) => ({ (acc, e) => ({
amount: acc.amount + (Number(e.amount) || 0), amount: acc.amount + (Number(e.amount) || 0),
@ -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 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'); 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 ------------------ //------------------ saving payload ------------------
export const buildExpenseSavePayload = (args: { export const buildExpenseSavePayload = (args: PayPeriodExpenses): PayPeriodExpenses => ({
pay_period_no: number;
pay_year: number;
email: string;
expenses: TimesheetExpense[];
}): ExpenseSavePayload => ({
pay_period_no: args.pay_period_no, pay_period_no: args.pay_period_no,
pay_year: args.pay_year, pay_year: args.pay_year,
email: args.email, employee_email: args.employee_email,
is_approved: args.is_approved ?? false,
expenses: args.expenses, 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 { COMMENT_MAX_LENGTH, DATE_FORMAT_PATTERN } from "src/modules/timesheets/constants/expense.constants";
import { ExpensesValidationError } from "../types/expense-validation.interface"; import { ExpensesValidationError } from "src/modules/timesheets/models/expense.validation";
import type { TimesheetExpense } from "../types/expense.interfaces"; import { type Expense, type ExpenseType, TYPES_WITH_AMOUNT_ONLY, TYPES_WITH_MILEAGE_ONLY } from "src/modules/timesheets/models/expense.models";
import {
type ExpenseType,
TYPES_WITH_AMOUNT_ONLY,
TYPES_WITH_MILEAGE_ONLY
} from "../types/expense.types";
//normalization helpers //normalization helpers
export const toNumOrUndefined = (value: unknown): number | undefined => { export const toNumOrUndefined = (value: unknown): number | undefined => {
if(value === undefined || value === null || (typeof value === 'string' && value.trim() === '')) return undefined; if(value === undefined || value === null || (typeof value === 'string' && value.trim() === '')) return undefined;
const num = Number(value); const num = Number(value);
return Number.isFinite(num) ? num : undefined; return Number.isFinite(num) ? num : undefined;
}; };
export const normalizeComment = (input?: string): string | undefined => { export const normalizeComment = (input?: string): string | undefined => {
if(typeof input === 'undefined' || input === null) return undefined; if(typeof input === 'undefined' || input === null) return undefined;
const trimmed = String(input).trim(); const trimmed = String(input).trim();
return trimmed.length ? trimmed : undefined; return trimmed.length ? trimmed : undefined;
}; };
export const normalizeType = (input: string): string => (input ?? '').trim().toUpperCase(); 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 comment = normalizeComment(expense.comment);
const amount = toNumOrUndefined(expense.amount); const amount = toNumOrUndefined(expense.amount);
const mileage = toNumOrUndefined(expense.mileage); const mileage = toNumOrUndefined(expense.mileage);
return { return {
date: (expense.date ?? '').trim(), date: (expense.date ?? '').trim(),
type: normalizeType(expense.type), type: normalizeType(expense.type),
@ -40,7 +38,7 @@ export const normalizeExpense = (expense: TimesheetExpense): TimesheetExpense =>
}; };
//UI validation error messages //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); const expense = normalizeExpense(raw);
//Date input validation //Date input validation
@ -60,6 +58,7 @@ export const validateExpenseUI = (raw: TimesheetExpense, label: string = 'expens
context: { [label]: expense }, context: { [label]: expense },
}) })
} }
if((expense.comment.length ?? 0) > COMMENT_MAX_LENGTH) { if((expense.comment.length ?? 0) > COMMENT_MAX_LENGTH) {
throw new ExpensesValidationError({ throw new ExpensesValidationError({
status_code: 400, status_code: 400,
@ -117,7 +116,7 @@ export const validateExpenseUI = (raw: TimesheetExpense, label: string = 'expens
}; };
//totals per pay-period //totals per pay-period
export const compute_expense_totals = (items: TimesheetExpense[]) => items.reduce( export const compute_expense_totals = (items: Expense[]) => items.reduce(
(acc, raw) => { (acc, raw) => {
const expense = normalizeExpense(raw); const expense = normalizeExpense(raw);
if(typeof expense.amount === 'number' && expense.amount > 0) acc.amount += expense.amount; 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"; // import type { ShiftKey, ShiftPayload, ShiftSelectOption } from "../types/shift.types";
/* eslint-disable */
export const toShiftPayload = (shift: any): ShiftPayload => ({ // export const toShiftPayload = (shift: any): ShiftPayload => ({
start_time: String(shift.start_time), // start_time: String(shift.start_time),
end_time: String(shift.end_time), // end_time: String(shift.end_time),
type: String(shift.type).toUpperCase() as ShiftKey, // type: String(shift.type).toUpperCase() as ShiftKey,
is_remote: !!shift.is_remote, // is_remote: !!shift.is_remote,
...(shift.comment ? { comment: String(shift.comment) } : {}), // ...(shift.comment ? { comment: String(shift.comment) } : {}),
}); // });
export const buildShiftOptions = ( // export const buildShiftOptions = (
keys: readonly string[], // keys: readonly string[],
t:(k: string) => string // t:(k: string) => string
): ShiftSelectOption[] => // ): ShiftSelectOption[] =>
keys.map((key) => ({ // keys.map((key) => ({
value: key as any, // value: key as any,
label: t(`timesheet.shift.types.${key}`), // label: t(`timesheet.shift.types.${key}`),
})); // }));

View File

@ -1,14 +1,11 @@
<script setup lang="ts">
/* eslint-disable */
</script>
<template> <template>
<q-layout view="hHh lpR fFf"> <q-layout view="hHh lpR fFf">
<q-page-container> <q-page-container>
<q-page padding class="column justify-center items-center bg-secondary"> <q-page padding class="column justify-center items-center bg-secondary">
<q-card class="col-shrink rounded-20"> <q-card class="col-shrink rounded-20">
<q-img src="src/assets/line-truck-1.jpg" height="20vh"> <q-img src="src/assets/line-truck-1.jpg" height="20vh">
<div class="absolute-bottom text-h4 text-center text-weight-bolder justify-center items-center row"> <div
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> <div class="q-pr-md text-primary text-h3 text-weight-bolder">404</div>
PAGE NOT FOUND PAGE NOT FOUND
</div> </div>

View File

@ -1,4 +1,7 @@
<script setup lang="ts"> <script
setup
lang="ts"
>
import { useQuasar } from 'quasar'; import { useQuasar } from 'quasar';
import type { QVueGlobals } from 'quasar'; import type { QVueGlobals } from 'quasar';
@ -12,7 +15,10 @@
</script> </script>
<template> <template>
<q-page padding class="q-pa-md row items-center justify-center"> <q-page
padding
class="q-pa-md row items-center justify-center"
>
<q-card class="shadow-2 col-9 dark-font"> <q-card class="shadow-2 col-9 dark-font">
<q-img src="src/assets/line-truck-1.jpg"> <q-img src="src/assets/line-truck-1.jpg">
<div class="absolute-bottom text-h5"> <div class="absolute-bottom text-h5">
@ -28,29 +34,40 @@
dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora 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 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 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 vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum
qui
dolorem eum fugiat quo voluptas nulla pariatur? dolorem eum fugiat quo voluptas nulla pariatur?
</q-card-section> </q-card-section>
<q-card-section class="text-center"> <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. 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>
<q-card-section class="text-center"> <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. 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>
<q-separator /> <q-separator />
<q-card-actions align="center"> <q-card-actions align="center">
<q-btn color="primary" label="Click Me" @click="clickNotify" /> <q-btn
color="primary"
label="Click Me"
@click="clickNotify"
/>
</q-card-actions> </q-card-actions>
</q-card> </q-card>
</q-page> </q-page>
</template> </template>
<style scoped>
.dark-font {
color: #676;
}
</style>

View File

@ -1,88 +1,124 @@
import { ref } from "vue"; import { ref } from "vue";
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { type PayPeriodExpenses } from "src/modules/timesheets/types/expense.interfaces"; import { useTimesheetStore } from "src/stores/timesheet-store";
import { ExpensesApiError } from "src/modules/timesheets/types/expense-validation.interface"; import
import { getPayPeriodExpenses, putPayPeriodExpenses } from "src/modules/timesheets/composables/api/use-expense-api"; 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', () => { export const useExpensesStore = defineStore('expenses', () => {
const is_dialog_open = ref(false); const is_open = ref(false);
const is_loading = ref(false); const is_loading = ref(false);
const data = ref<PayPeriodExpenses | null>(null); 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 error = ref<string | null>(null);
const setErrorFrom = (err: unknown, t?: (_key: string) => string) => { const setErrorFrom = (err: unknown) => {
const e = err as any; const e = err as any;
error.value = (err instanceof ExpensesApiError && t error.value = e?.message || 'Unknown error';
? t(e.message): undefined)
|| e?.message
|| 'Unknown error';
}; };
const openDialog = async ( const open = async (employee_email: string) => {
params: { email: string; pay_year: number; pay_period_no: number; t?: (_key: string)=> string}) => { is_open.value = true;
is_dialog_open.value = true;
is_loading.value = true; is_loading.value = true;
error.value = null; error.value = null;
try { try {
const response = await getPayPeriodExpenses( const response = await getPayPeriodExpenses(employee_email, pay_period.pay_year, pay_period.pay_period_no,);
params.email, current_expenses.value = response;
params.pay_year, initial_expenses.value = unwrapAndClone(response);
params.pay_period_no,
);
data.value = response;
} catch (err) { } catch (err) {
setErrorFrom(err, params.t); setErrorFrom(err);
data.value = { current_expenses.value = default_pay_period_expenses;
pay_period_no: params.pay_period_no, initial_expenses.value = default_pay_period_expenses;
pay_year: params.pay_year, } finally {
employee_email: params.email, 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, is_approved: false,
expenses: [], expenses: payload(),
totals: { amount: 0, mileage: 0}, }));
};
} finally {
is_loading.value = false;
}
}
const saveExpenses = async (payload: { } catch (err: any) {
email: string; emit('error', toExpensesError(err));
pay_year: number; }
pay_period_no: number; };
expenses: any[]; t?: (_key: string) => string
}) => { 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; is_loading.value = true;
error.value = null; error.value = null;
try { try {
const updated = await putPayPeriodExpenses( const updated = await putPayPeriodExpenses(employee_email, pay_period.pay_year, pay_period.pay_period_no, expenses);
payload.email, pay_period_expenses.value = updated;
payload.pay_year, is_open.value = false;
payload.pay_period_no,
payload.expenses
);
data.value = updated;
is_dialog_open.value = false;
} catch (err) { } catch (err) {
setErrorFrom(err, payload.t); setErrorFrom(err);
} finally { } finally {
is_loading.value = false; is_loading.value = false;
} }
}; };
const closeDialog = () => { const close = () => {
error.value = null; error.value = null;
is_dialog_open.value = false; is_open.value = false;
}; };
return { return {
is_dialog_open, is_open,
is_loading, is_loading,
data, current_expenses,
initial_expenses,
error, error,
openDialog, open,
saveExpenses, upsertOrDeletePayPeriodExpenseByEmployeeEmail,
closeDialog, close,
}; };
}); });

View File

@ -1,50 +1,80 @@
import { defineStore } from "pinia";
import { ref } from "vue"; import { ref } from "vue";
import { toShiftPayload } from "src/modules/timesheets/utils/shift.util"; import { defineStore } from "pinia";
import type { FormMode } from "src/modules/timesheets/types/ui.types"; import { unwrapAndClone } from "src/utils/unwrap-and-clone";
import type { ShiftPayload } from "src/modules/timesheets/types/shift.types"; 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', () => { export const useShiftStore = defineStore('shift', () => {
const is_open = ref(false); const is_open = ref(false);
const mode = ref<FormMode>('create'); const mode = ref<UpsertAction>('create');
const date_iso = ref<string>(''); const date_iso = ref<string>('');
const initial_shift = ref<ShiftPayload | null>(null); const current_shift = ref<Shift>(default_shift);
const initial_shift = ref<Shift>(default_shift);
const open = (nextMode: FormMode, date: string, payload: ShiftPayload | null) => { const timesheet_store = useTimesheetStore();
mode.value = nextMode;
const open = (next_mode: UpsertAction, date: string, current: Shift, initial: Shift) => {
mode.value = next_mode;
date_iso.value = date; date_iso.value = date;
initial_shift.value = payload; current_shift.value = current; // new shift
initial_shift.value = initial; // old shift
is_open.value = true; is_open.value = true;
}; };
const openCreate = (date: string) => { const openCreate = (date: string) => {
open('create', date, null); open('create', date, default_shift, default_shift);
}; };
const openEdit = (date: string, shift: any) => { const openUpdate = (date: string, shift: Shift) => {
open('edit', date, toShiftPayload(shift as any)); open('update', date, shift, unwrapAndClone(shift));
}; };
const openDelete = (date: string, shift: any) => { const openDelete = (date: string, shift: any) => {
open('delete', date, toShiftPayload(shift as any)); open('delete', date, default_shift, shift);
} }
const close = () => { const close = () => {
is_open.value = false; is_open.value = false;
mode.value = 'create'; mode.value = 'create';
date_iso.value = ''; date_iso.value = '';
initial_shift.value = null; 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 { return {
is_open, is_open,
mode, mode,
date_iso, date_iso,
current_shift,
initial_shift, initial_shift,
openCreate, openCreate,
openEdit, openUpdate,
openDelete, openDelete,
close, close,
upsertOrDeleteShiftByEmployeeEmail,
}; };
}) })

View File

@ -3,21 +3,18 @@ import { computed, ref } from 'vue';
import { withLoading } from 'src/utils/store-helpers'; import { withLoading } from 'src/utils/store-helpers';
import { timesheetApprovalService } from 'src/modules/timesheet-approval/services/timesheet-approval-service'; import { timesheetApprovalService } from 'src/modules/timesheet-approval/services/timesheet-approval-service';
import { timesheetService } from 'src/modules/timesheets/services/timesheet-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 { default_pay_period_overview, type PayPeriodOverview } from "src/modules/timesheet-approval/models/pay-period-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, type PayPeriod } from 'src/modules/shared/types/pay-period-interface'; 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', () => { export const useTimesheetStore = defineStore('timesheet', () => {
const is_loading = ref<boolean>(false); const is_loading = ref<boolean>(false);
const pay_period = ref<PayPeriod>(default_pay_period); const pay_period = ref<PayPeriod>(default_pay_period);
const timesheet_approval_overview_list = ref<TimesheetApprovalOverview[]>([]); const pay_period_overviews = ref<PayPeriodOverview[]>([ default_pay_period_overview, ]);
const timesheet_aproval_overview = ref<TimesheetApprovalOverview>(default_pay_period_employee_overview); const current_pay_period_overview = ref<PayPeriodOverview>(default_pay_period_overview);
const pay_period_employee_details = ref<TimesheetDetails>(default_timesheet_details); const pay_period_details = ref<PayPeriodDetails>(default_pay_period_details);
const pay_period_report = ref(); const pay_period_report = ref();
// const timesheet = ref<Timesheet>(default_timesheet);
const is_calendar_limit = computed( ()=> const is_calendar_limit = computed( ()=>
pay_period.value.pay_year === 2024 && pay_period.value.pay_year === 2024 &&
pay_period.value.pay_period_no <= 1 pay_period.value.pay_period_no <= 1
@ -29,11 +26,11 @@ export const useTimesheetStore = defineStore('timesheet', () => {
let response; let response;
if (typeof date_or_year === 'string') { if (typeof date_or_year === 'string') {
response = await timesheetApprovalService.getPayPeriodByDate(date_or_year); response = await timesheetService.getPayPeriodByDate(date_or_year);
return true; return true;
} }
else if ( typeof date_or_year === 'number' && period_number ) { 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; return true;
} }
else response = default_pay_period; else response = default_pay_period;
@ -43,7 +40,7 @@ export const useTimesheetStore = defineStore('timesheet', () => {
} catch(error){ } catch(error){
console.error('Could not get current pay period: ', error ); console.error('Could not get current pay period: ', error );
pay_period.value = default_pay_period; 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 //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 () => { return withLoading( is_loading, async () => {
try { try {
const response = await timesheetApprovalService.getPayPeriodEmployeeOverviewListBySupervisorEmail( pay_year, period_number, supervisor_email ); const response = await timesheetService.getPayPeriodDetailsByPayPeriodAndEmployeeEmail(
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(
pay_period.value.pay_year, pay_period.value.pay_year,
pay_period.value.pay_period_no, pay_period.value.pay_period_no,
employee_email employee_email
); );
pay_period_employee_details.value = response; pay_period_details.value = response;
return true; return true;
} catch (error) { } catch (error) {
@ -110,17 +64,33 @@ export const useTimesheetStore = defineStore('timesheet', () => {
// TODO: More in-depth error-handling here // 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; 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 () => { return withLoading( is_loading, async () => {
try { try {
const response = await timesheetApprovalService.getTimesheetApprovalCSVReport( const response = await timesheetApprovalService.getPayPeriodOverviewsBySupervisorEmail( pay_year, period_number, supervisor_email );
pay_period.value.pay_year, pay_period_overviews.value = response;
pay_period.value.pay_period_no, 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 report_filters
); );
pay_period_report.value = response; pay_period_report.value = response;
@ -136,18 +106,15 @@ export const useTimesheetStore = defineStore('timesheet', () => {
}; };
return { return {
pay_period,
pay_period_employee_overview_list,
pay_period_employee_overview,
pay_period_employee_details,
timesheet,
is_loading, is_loading,
is_calendar_limit, is_calendar_limit,
pay_period,
pay_period_overviews,
current_pay_period_overview,
pay_period_details,
getPayPeriodByDateOrYearAndNumber, getPayPeriodByDateOrYearAndNumber,
// getTimesheetByEmail, getPayPeriodOverviewsBySupervisorEmail,
getPayPeriodEmployeeOverviewListBySupervisorEmail, getPayPeriodDetailsByEmployeeEmail,
getPayPeriodOverviewByEmployeeEmail, getPayPeriodReportByYearAndPeriodNumber,
getPayPeriodEmployeeDetailsByEmployeeEmail,
getTimesheetApprovalCSVReport,
}; };
}); });

View File

@ -1,28 +1,41 @@
export const deepEqual = (a: unknown, b: unknown): boolean => { import { unwrapAndClone } from "src/utils/unwrap-and-clone";
if (a === b) {
return true; /**
* Internal recursive function comparing two plain values.
*/
const _deepEqualRecursive = (a: unknown, b: unknown): boolean => {
if (a === b) return true;
if (a == null || b == null || typeof a !== "object" || typeof b !== "object") {
return false;
} }
if ( // Handle arrays
a == null || // handles null and undefined if (Array.isArray(a) && Array.isArray(b)) {
b == null || if (a.length !== b.length) return false;
typeof a !== 'object' || return a.every((val, i) => _deepEqualRecursive(val, b[i]));
typeof b !== 'object' } else if (Array.isArray(a) || Array.isArray(b)) {
) { return false; // one is array, other is not
return false;
} }
const aKeys = Object.keys(a as Record<string, unknown>); const aKeys = Object.keys(a as Record<string, unknown>);
const bKeys = Object.keys(b as Record<string, unknown>); const bKeys = Object.keys(b as Record<string, unknown>);
if (aKeys.length !== bKeys.length) { if (aKeys.length !== bKeys.length) return false;
return false;
}
return aKeys.every((key) => return aKeys.every((key) =>
deepEqual( _deepEqualRecursive(
(a as Record<string, unknown>)[key], (a as Record<string, unknown>)[key],
(b 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: boolean, fn: () => Promise<T> ) => {
loading_state = true;
export const withLoading = async <T>( loading_state: Ref<boolean>, fn: () => Promise<T> ) => {
loading_state.value = true;
try { try {
return await fn(); return await fn();
} finally { } 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));
};