BREAKING(refactor): more refactor to streamline and standardize approvals and timesheet, pull to get expense changes
This commit is contained in:
parent
655a7ecff1
commit
00f5565fe5
|
|
@ -1,8 +1,8 @@
|
|||
<script setup lang="ts">
|
||||
const { title, startDate = "", endDate = "" } = defineProps<{
|
||||
title: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}>();
|
||||
|
||||
const date_format_options = { day: 'numeric', month: 'long', year: 'numeric', };
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
};
|
||||
|
|
@ -5,7 +5,7 @@
|
|||
import { useI18n } from 'vue-i18n';
|
||||
import { useQuasar } from 'quasar';
|
||||
import { Chart as ChartJS, Title, Tooltip, Legend, BarElement, CategoryScale, LinearScale, type ChartData, type ChartOptions, type Plugin, type ChartDataset } from 'chart.js';
|
||||
import type { PayPeriodEmployeeDetails } from 'src/modules/timesheet-approval/types/pay-period-employee-details';
|
||||
import type { TimesheetDetails } from 'src/modules/timesheets/models/pay-period-details.models';
|
||||
|
||||
const { t } = useI18n();
|
||||
const $q = useQuasar();
|
||||
|
|
@ -16,7 +16,7 @@
|
|||
ChartJS.defaults.color = $q.dark.isActive ? '#F5F5F5' : '#616161';
|
||||
|
||||
const props = defineProps<{
|
||||
rawData: PayPeriodEmployeeDetails | undefined;
|
||||
rawData: TimesheetDetails | undefined;
|
||||
options?: ChartOptions<"bar"> | undefined;
|
||||
plugins?: Plugin<"bar">[] | undefined;
|
||||
}>();
|
||||
|
|
@ -5,15 +5,15 @@
|
|||
import DetailedDialogChartHoursWorked from 'src/modules/timesheet-approval/components/graphs/detailed-chart-hours-worked.vue';
|
||||
import DetailedDialogChartShiftTypes from 'src/modules/timesheet-approval/components/graphs/detailed-chart-shift-types.vue';
|
||||
import DetailedDialogChartExpenses from 'src/modules/timesheet-approval/components/graphs/detailed-chart-expenses.vue';
|
||||
import type { TimesheetApprovalOverviewCrewMember } from 'src/modules/timesheet-approval/models/timesheet-approval-overview.models';
|
||||
import type { TimesheetDetails } from 'src/modules/timesheets/models/timesheet.models';
|
||||
import type { PayPeriodOverview } from 'src/modules/timesheet-approval/models/pay-period-overview.models';
|
||||
import type { PayPeriodDetails } from 'src/modules/timesheets/models/pay-period-details.models';
|
||||
|
||||
const dialog_model = defineModel<boolean>('dialog', { default: false });
|
||||
|
||||
defineProps<{
|
||||
isLoading: boolean;
|
||||
employeeOverview: TimesheetApprovalOverviewCrewMember;
|
||||
timesheetDetails: TimesheetDetails;
|
||||
payPeriodOverview: PayPeriodOverview;
|
||||
payPeriodDetails: PayPeriodDetails;
|
||||
}>();
|
||||
|
||||
// const timesheet_store = useTimesheetStore();
|
||||
|
|
@ -52,7 +52,7 @@
|
|||
v-if="!isLoading"
|
||||
class="text-h5 text-weight-bolder text-center text-primary q-pa-none text-uppercase col-auto"
|
||||
>
|
||||
<span> {{ timesheetDetails.employee_full_name }} </span>
|
||||
<span> {{ payPeriodDetails.employee_full_name }} </span>
|
||||
|
||||
<q-separator
|
||||
spaced
|
||||
|
|
@ -114,7 +114,7 @@
|
|||
style="min-height: 300px;"
|
||||
>
|
||||
<DetailedDialogChartHoursWorked
|
||||
:raw-data="timesheetDetails"
|
||||
:raw-data="payPeriodDetails"
|
||||
class="col-7"
|
||||
/>
|
||||
|
||||
|
|
@ -125,7 +125,7 @@
|
|||
|
||||
<div class="column col justify-center no-wrap q-pa-none">
|
||||
<DetailedDialogChartShiftTypes
|
||||
:raw-data="employeeOverview"
|
||||
:raw-data="payPeriodOverview"
|
||||
class="col-5"
|
||||
/>
|
||||
|
||||
|
|
@ -135,7 +135,7 @@
|
|||
/>
|
||||
|
||||
<DetailedDialogChartExpenses
|
||||
:raw-data="timesheetDetails"
|
||||
:raw-data="payPeriodDetails"
|
||||
class="col"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -1,17 +1,16 @@
|
|||
import { useTimesheetStore } from "src/stores/timesheet-store";
|
||||
import { useAuthStore } from "src/stores/auth-store";
|
||||
import type { TimesheetApprovalCSVReportFilters } from "src/modules/timesheet-approval/models/timesheet-approval-csv-report.models";
|
||||
import { date } from "quasar";
|
||||
|
||||
export const useTimesheetApprovalApi = () => {
|
||||
const timesheet_store = useTimesheetStore();
|
||||
const auth_store = useAuthStore();
|
||||
|
||||
const getPayPeriodOverviewByDate = async (date_string: string): Promise<void> => {
|
||||
const getPayPeriodOverviewsByDate = async (date_string: string): Promise<void> => {
|
||||
const success = await timesheet_store.getPayPeriodByDateOrYearAndNumber(date_string);
|
||||
|
||||
if (success) {
|
||||
await timesheet_store.getPayPeriodEmployeeOverviewListBySupervisorEmail(
|
||||
await timesheet_store.getPayPeriodOverviewsBySupervisorEmail(
|
||||
timesheet_store.pay_period.pay_year,
|
||||
timesheet_store.pay_period.pay_period_no,
|
||||
auth_store.user.email
|
||||
|
|
@ -19,35 +18,7 @@ export const useTimesheetApprovalApi = () => {
|
|||
}
|
||||
};
|
||||
|
||||
/* This method attempts to get the next or previous pay period.
|
||||
It checks if pay period number is within a certain range, adjusts pay period and year accordingly.
|
||||
It then requests the matching pay period object to set as current pay period from server.
|
||||
If successful, it then requests pay period overviews from that new pay period, using either the current user or
|
||||
any other supervisor email provided. */
|
||||
const getNextOrPreviousPayPeriodOverviewList = async (direction: number, supervisor_email?: string): Promise<void> => {
|
||||
const email = supervisor_email ?? auth_store.user.email;
|
||||
|
||||
let new_pay_period_no = timesheet_store.pay_period.pay_period_no + direction;
|
||||
let new_pay_year = timesheet_store.pay_period.pay_year;
|
||||
|
||||
if (new_pay_period_no > 26) {
|
||||
new_pay_period_no = 1;
|
||||
new_pay_year += 1;
|
||||
}
|
||||
|
||||
if (new_pay_period_no < 1) {
|
||||
new_pay_period_no = 26;
|
||||
new_pay_year -= 1;
|
||||
}
|
||||
|
||||
const success = await timesheet_store.getPayPeriodByDateOrYearAndNumber(new_pay_year, new_pay_period_no);
|
||||
|
||||
if (success) {
|
||||
await timesheet_store.getPayPeriodEmployeeOverviewListBySupervisorEmail(new_pay_year, new_pay_period_no, email);
|
||||
}
|
||||
};
|
||||
|
||||
const getTimesheetApprovalCSVReport = async ( report_filter_company: boolean[], report_filter_type: boolean[] ) => {
|
||||
const getTimesheetApprovalCSVReport = async ( report_filter_company: boolean[], report_filter_type: boolean[], year?: number, period_number?: number ) => {
|
||||
const [ targo, solucom ] = report_filter_company;
|
||||
const [ shifts, expenses, holiday, vacation ] = report_filter_type;
|
||||
const options = {
|
||||
|
|
@ -55,12 +26,15 @@ export const useTimesheetApprovalApi = () => {
|
|||
companies: { targo, solucom },
|
||||
} as TimesheetApprovalCSVReportFilters;
|
||||
|
||||
await timesheet_store.getTimesheetApprovalCSVReport(options);
|
||||
await timesheet_store.getPayPeriodReportByYearAndPeriodNumber(
|
||||
year ?? timesheet_store.pay_period.pay_year,
|
||||
period_number ?? timesheet_store.pay_period.pay_period_no,
|
||||
options
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
getPayPeriodOverviewByDate,
|
||||
getNextOrPreviousPayPeriodOverviewList,
|
||||
getPayPeriodOverviewsByDate,
|
||||
getTimesheetApprovalCSVReport,
|
||||
}
|
||||
};
|
||||
|
|
@ -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,
|
||||
}
|
||||
];
|
||||
|
|
@ -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,
|
||||
}
|
||||
];
|
||||
|
|
@ -1,33 +1,14 @@
|
|||
import { api } from "src/boot/axios";
|
||||
import type { PayPeriodOverview } from "src/modules/timesheet-approval/types/pay-period-overview";
|
||||
import type { PayPeriod } from "src/modules/shared/types/pay-period-interface";
|
||||
import type { PayPeriodEmployeeDetails } from "src/modules/timesheet-approval/types/pay-period-employee-details";
|
||||
import type { PayPeriodReportFilters } from "src/modules/timesheet-approval/types/pay-period-report";
|
||||
import type { TimesheetApprovalCSVReportFilters } from "src/modules/timesheet-approval/models/timesheet-approval-csv-report.models";
|
||||
import type { PayPeriodOverview } from "src/modules/timesheet-approval/models/pay-period-overview.models";
|
||||
|
||||
export const timesheetApprovalService = {
|
||||
getPayPeriodByDate: async (date_string: string): Promise<PayPeriod> => {
|
||||
const response = await api.get(`pay-periods/date/${date_string}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getPayPeriodByYearAndPeriodNumber: async (year: number, period_number: number): Promise<PayPeriod> => {
|
||||
const response = await api.get(`pay-periods/${year}/${period_number}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
|
||||
getPayPeriodEmployeeOverviewListBySupervisorEmail: async (year: number, period_number: number, supervisor_email: string): Promise<PayPeriodOverview> => {
|
||||
// TODO: REMOVE MOCK DATA PEFORE PUSHING TO PROD
|
||||
getPayPeriodOverviewsBySupervisorEmail: async (year: number, period_number: number, supervisor_email: string): Promise<PayPeriodOverview[]> => {
|
||||
const response = await api.get(`pay-periods/${year}/${period_number}/${supervisor_email}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getPayPeriodEmployeeDetailsByPayPeriodAndEmail: async (year: number, period_no: number, email: string): Promise<PayPeriodEmployeeDetails> => {
|
||||
const response = await api.get('timesheets', { params: { year, period_no, email, }});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getTimesheetApprovalCSVReport: async (year: number, period_number: number, report_filters?: PayPeriodReportFilters) => {
|
||||
getPayPeriodReportByYearAndPeriodNumber: async (year: number, period_number: number, report_filters?: TimesheetApprovalCSVReportFilters) => {
|
||||
const response = await api.get(`csv/${year}/${period_number}`, { params: { report_filters, }});
|
||||
return response.data;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,44 +1,44 @@
|
|||
<script setup lang="ts">
|
||||
import type { TimesheetExpense } from '../../types/expense.interfaces';
|
||||
import type { ExpenseType } from '../../types/expense.types';
|
||||
/* eslint-disable */
|
||||
<script
|
||||
setup
|
||||
lang="ts"
|
||||
>
|
||||
import type { ExpenseType, Expense } from 'src/modules/timesheets/models/expense.models';
|
||||
import { ref } from 'vue';
|
||||
|
||||
//---------------- v-models ------------------
|
||||
const draft = defineModel<Partial<TimesheetExpense>>('draft');
|
||||
const files = defineModel<File[] | null>('files');
|
||||
const datePickerOpen = defineModel<boolean | null>('datePickerOpen', {default: false });
|
||||
const files = defineModel<File[] | null>('files');
|
||||
const is_navigator_open = ref(false);
|
||||
|
||||
//------------------ props ------------------
|
||||
const props = defineProps<{
|
||||
type_options: { label: string; value: ExpenseType }[];
|
||||
show_amount: boolean;
|
||||
is_readonly: boolean;
|
||||
rules: {
|
||||
typeRequired: (val: unknown) => true | string;
|
||||
amountRequired: (val: unknown) => true | string;
|
||||
mileageRequired: (val: unknown) => true | string;
|
||||
commentRequired: (val: unknown) => true | string;
|
||||
commentTooLong: (val: unknown) => true | string;
|
||||
};
|
||||
comment_max_length: number;
|
||||
setType: (val: ExpenseType) => void;
|
||||
}>();
|
||||
|
||||
//------------------ emits ------------------
|
||||
const emit = defineEmits<{
|
||||
(e: 'submit'): void;
|
||||
}>();
|
||||
//------------------ props ------------------
|
||||
defineProps<{
|
||||
|
||||
type_options: { label: string; value: ExpenseType }[];
|
||||
show_amount: boolean;
|
||||
is_readonly: boolean;
|
||||
rules: {
|
||||
typeRequired: (val: unknown) => true | string;
|
||||
amountRequired: (val: unknown) => true | string;
|
||||
mileageRequired: (val: unknown) => true | string;
|
||||
commentRequired: (val: unknown) => true | string;
|
||||
commentTooLong: (val: unknown) => true | string;
|
||||
};
|
||||
comment_max_length: number;
|
||||
setType: (val: ExpenseType) => void;
|
||||
}>();
|
||||
|
||||
//------------------ emits ------------------
|
||||
const emit = defineEmits<{
|
||||
'submit': [void];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-form
|
||||
flat
|
||||
<q-form
|
||||
flat
|
||||
v-if="!is_readonly"
|
||||
@submit.prevent="emit('submit')"
|
||||
>
|
||||
<div class="text-subtitle2 q-py-sm">
|
||||
{{ $t('timesheet.expense.add_expense')}}
|
||||
{{ $t('timesheet.expense.add_expense') }}
|
||||
</div>
|
||||
<div class="row justify-between">
|
||||
|
||||
|
|
@ -53,22 +53,22 @@ const emit = defineEmits<{
|
|||
color="primary"
|
||||
:label="$t('timesheet.expense.date')"
|
||||
>
|
||||
<template #before>
|
||||
<q-btn
|
||||
push
|
||||
dense
|
||||
icon="event"
|
||||
color="primary"
|
||||
@click="datePickerOpen = true"
|
||||
/>
|
||||
<q-dialog v-model="datePickerOpen">
|
||||
<q-date
|
||||
v-model="draft!.date"
|
||||
@update:model-value="datePickerOpen = false"
|
||||
mask="YYYY-MM-DD"
|
||||
/>
|
||||
</q-dialog>
|
||||
</template>
|
||||
<template #before>
|
||||
<q-btn
|
||||
push
|
||||
dense
|
||||
icon="event"
|
||||
color="primary"
|
||||
@click="datePickerOpen = true"
|
||||
/>
|
||||
<q-dialog v-model="datePickerOpen">
|
||||
<q-date
|
||||
v-model="draft!.date"
|
||||
@update:model-value="datePickerOpen = false"
|
||||
mask="YYYY-MM-DD"
|
||||
/>
|
||||
</q-dialog>
|
||||
</template>
|
||||
</q-input>
|
||||
|
||||
<!-- expenses type selection -->
|
||||
|
|
@ -82,7 +82,8 @@ const emit = defineEmits<{
|
|||
emit-value
|
||||
map-options
|
||||
:label="$t('timesheet.expense.type')"
|
||||
:rules="[ rules.typeRequired ]"
|
||||
:rules="[rules.typeRequired]"
|
||||
:option-label="label => $t(label)"
|
||||
@update:model-value="val => setType(val as ExpenseType)"
|
||||
/>
|
||||
|
||||
|
|
@ -101,7 +102,7 @@ const emit = defineEmits<{
|
|||
:label="$t('timesheet.expense.amount')"
|
||||
suffix="$"
|
||||
lazy-rules="ondemand"
|
||||
:rules="[ rules.amountRequired ]"
|
||||
:rules="[rules.amountRequired]"
|
||||
/>
|
||||
</template>
|
||||
|
||||
|
|
@ -120,7 +121,7 @@ const emit = defineEmits<{
|
|||
:label="$t('timesheet.expense.mileage')"
|
||||
suffix="km"
|
||||
lazy-rules="ondemand"
|
||||
:rules="[ rules.mileageRequired ]"
|
||||
:rules="[rules.mileageRequired]"
|
||||
/>
|
||||
</template>
|
||||
|
||||
|
|
@ -137,7 +138,7 @@ const emit = defineEmits<{
|
|||
:counter="true"
|
||||
:maxlength="comment_max_length"
|
||||
lazy-rules="ondemand"
|
||||
:rules="[ rules.commentRequired, rules.commentTooLong ]"
|
||||
:rules="[rules.commentRequired, rules.commentTooLong]"
|
||||
>
|
||||
<template #label>
|
||||
<span class="text-weight-bold ">
|
||||
|
|
@ -163,11 +164,10 @@ const emit = defineEmits<{
|
|||
name="attach_file"
|
||||
size="sm"
|
||||
color="primary"
|
||||
|
||||
/>
|
||||
</template>
|
||||
</q-file>
|
||||
|
||||
|
||||
<!-- add btn section -->
|
||||
<div>
|
||||
<q-btn
|
||||
|
|
@ -178,7 +178,7 @@ const emit = defineEmits<{
|
|||
size="sm"
|
||||
class="q-mt-sm q-ml-sm"
|
||||
type="submit"
|
||||
/>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</q-form>
|
||||
|
|
@ -1,26 +1,29 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
/* eslint-disable */
|
||||
const props = defineProps<{
|
||||
commentString: string;
|
||||
}>();
|
||||
<script
|
||||
setup
|
||||
lang="ts"
|
||||
>
|
||||
import { ref } from 'vue';
|
||||
|
||||
const emit = defineEmits<{
|
||||
clickClose: [];
|
||||
clickSave: [comment: string];
|
||||
}>();
|
||||
const { commentString } = defineProps<{
|
||||
commentString: string;
|
||||
}>();
|
||||
|
||||
const text = ref(props.commentString);
|
||||
const emit = defineEmits<{
|
||||
clickClose: [];
|
||||
clickSave: [comment: string];
|
||||
}>();
|
||||
|
||||
const close = ()=> {
|
||||
emit('clickClose');
|
||||
text.value = '';
|
||||
}
|
||||
const text = ref(commentString);
|
||||
|
||||
const save = ()=> {
|
||||
emit('clickSave',text.value);
|
||||
close();
|
||||
}
|
||||
const close = () => {
|
||||
emit('clickClose');
|
||||
text.value = '';
|
||||
}
|
||||
|
||||
const save = () => {
|
||||
emit('clickSave', text.value);
|
||||
close();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -28,14 +31,14 @@ const save = ()=> {
|
|||
<div class="row items-center justify-between q-pa-md">
|
||||
{{ $t('timesheet.fields.header_comment') }}
|
||||
</div>
|
||||
<q-separator/>
|
||||
<q-separator />
|
||||
<div class="q-pa-md">
|
||||
<q-input
|
||||
v-model="text"
|
||||
type="textarea"
|
||||
autogrow
|
||||
filled
|
||||
:label= "$t('timesheet.fields.textarea_comment')"
|
||||
:label="$t('timesheet.fields.textarea_comment')"
|
||||
:counter=true
|
||||
maxlength="512"
|
||||
color="primary"
|
||||
|
|
@ -46,8 +49,8 @@ const save = ()=> {
|
|||
color="secondary"
|
||||
text-color="grey-8"
|
||||
:label="$t('timesheet.cancel_button')"
|
||||
@click="close"
|
||||
/>
|
||||
@click="close"
|
||||
/>
|
||||
<q-btn
|
||||
:label="$t('timesheet.save_button')"
|
||||
color="primary"
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -1,214 +1,149 @@
|
|||
<script setup lang="ts">
|
||||
<script
|
||||
setup
|
||||
lang="ts"
|
||||
>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useShiftStore } from 'src/stores/shift-store';
|
||||
import { useShiftApi } from 'src/modules/timesheets/composables/api/use-shift-api';
|
||||
import { SHIFT_TYPES } from 'src/modules/timesheets/models/shift.models';
|
||||
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { SHIFT_KEY, type ShiftKey, type ShiftPayload, type ShiftSelectOption } from '../../types/shift.types';
|
||||
import type { UpsertShiftsBody } from '../../types/shift.interfaces';
|
||||
import { upsertShiftsByDate } from '../../composables/api/use-shift-api';
|
||||
const { date_iso, mode, current_shift, is_open, close } = useShiftStore();
|
||||
const { upsertOrDeleteShiftByEmployeeEmail } = useShiftApi();
|
||||
|
||||
const { t } = useI18n();
|
||||
const { employeeEmail } = defineProps<{
|
||||
employeeEmail: string;
|
||||
}>();
|
||||
|
||||
const props = defineProps<{
|
||||
mode: 'create' | 'edit' | 'delete';
|
||||
dateIso: string;
|
||||
initialShift?: ShiftPayload | null;
|
||||
shiftOptions: ShiftSelectOption[];
|
||||
email: string;
|
||||
}>();
|
||||
const isSubmitting = ref(false);
|
||||
const errorBanner = ref<string | null>(null);
|
||||
const conflicts = ref<Array<{ start_time: string; end_time: string; type: string }>>([]);
|
||||
|
||||
const emit = defineEmits<{
|
||||
'close': []
|
||||
'saved': []
|
||||
}>();
|
||||
const canSubmit = computed(() =>
|
||||
mode === 'delete' ||
|
||||
(current_shift.start_time.trim().length === 5 &&
|
||||
current_shift.end_time.trim().length === 5 &&
|
||||
current_shift.type !== undefined)
|
||||
);
|
||||
</script>
|
||||
|
||||
const isSubmitting = ref(false);
|
||||
const errorBanner = ref<string | null>(null);
|
||||
const conflicts = ref<Array<{start_time: string; end_time: string; type: string}>>([]);
|
||||
|
||||
const opened = defineModel<boolean> ( { default: false });
|
||||
const startTime = defineModel<string> ('startTime', { default: '' });
|
||||
const endTime = defineModel<string> ('endTime' , { default: '' });
|
||||
const type = defineModel<ShiftKey | ''> ('type' , { default: '' });
|
||||
const isRemote = defineModel<boolean> ('isRemote' , { default: false });
|
||||
const comment = defineModel<string> ('comment' , { default: '' });
|
||||
|
||||
const isShiftKey = (val: unknown): val is ShiftKey => SHIFT_KEY.includes(val as ShiftKey);
|
||||
|
||||
const buildNewShiftPayload = (): ShiftPayload => {
|
||||
if(!isShiftKey(type.value)) throw new Error('Invalid shift type');
|
||||
const trimmed = (comment.value ?? '').trim();
|
||||
return {
|
||||
start_time: startTime.value,
|
||||
end_time: endTime.value,
|
||||
type: type.value,
|
||||
is_remote: isRemote.value,
|
||||
...(trimmed ? { comment: trimmed } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
const onSubmit = async () => {
|
||||
errorBanner.value = null;
|
||||
conflicts.value = [];
|
||||
isSubmitting.value = true;
|
||||
|
||||
try{
|
||||
let body: UpsertShiftsBody;
|
||||
if(props.mode === 'create') {
|
||||
body = { new_shift: buildNewShiftPayload() };
|
||||
} else if (props.mode === 'edit') {
|
||||
if(!props.initialShift) throw new Error('Missing initial Shift for edit');
|
||||
body = { old_shift: props.initialShift, new_shift: buildNewShiftPayload() };
|
||||
} else {
|
||||
if (!props.initialShift) throw new Error('Missing initial Shift for delete.');
|
||||
body = { old_shift: props.initialShift };
|
||||
}
|
||||
await upsertShiftsByDate(props.email, props.dateIso, body);
|
||||
opened.value = false;
|
||||
emit('saved');
|
||||
} catch (error: any) {
|
||||
const status = error?.status_code ?? error.response?.status ?? 500;
|
||||
|
||||
const apiConflicts = error?.response?.data?.conflicts ?? error?.data?.conflicts;
|
||||
if(Array.isArray(apiConflicts)){
|
||||
conflicts.value = apiConflicts.map((c:any)=> ({
|
||||
start_time: String(c.start_time ?? ''),
|
||||
end_time: String(c.end_time ?? ''),
|
||||
type: String(c.type ?? ''),
|
||||
}));
|
||||
} else {
|
||||
conflicts.value = [];
|
||||
}
|
||||
|
||||
if (status === 404) errorBanner.value = t('timesheet.shift.errors.not_found')
|
||||
else if (status === 409) errorBanner.value = t('timesheet.shift.errors.overlap')
|
||||
else if (status === 422) errorBanner.value = t('timesheet.shift.errors.invalid')
|
||||
else errorBanner.value = t('timesheet.shift.errors.unknown')
|
||||
//add conflicts.value error management
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const hydrateFromProps = () => {
|
||||
if(props.mode === 'edit' || props.mode === 'delete') {
|
||||
const shift = props.initialShift;
|
||||
startTime.value = shift?.start_time ?? '';
|
||||
endTime.value = shift?.end_time ?? '';
|
||||
type.value = shift?.type ?? '';
|
||||
isRemote.value = !!shift?.is_remote;
|
||||
comment.value = (shift as any)?.comment ?? '';
|
||||
} else {
|
||||
startTime.value = '';
|
||||
endTime.value = '';
|
||||
type.value = '';
|
||||
isRemote.value = false;
|
||||
comment.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const canSubmit = computed(() =>
|
||||
props.mode === 'delete' ||
|
||||
(startTime.value.trim().length === 5 &&
|
||||
endTime.value.trim().length === 5 &&
|
||||
isShiftKey(type.value))
|
||||
);
|
||||
|
||||
watch(
|
||||
()=> [opened.value, props.mode, props.initialShift, props.dateIso],
|
||||
()=> { if (opened.value) hydrateFromProps();},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<!-- create/edit/delete shifts dialog -->
|
||||
<template>
|
||||
<q-dialog v-model="opened"
|
||||
persistent
|
||||
transition-show="fade"
|
||||
transition-hide="fade">
|
||||
<q-dialog
|
||||
v-model=" is_open"
|
||||
persistent
|
||||
transition-show="fade"
|
||||
transition-hide="fade"
|
||||
>
|
||||
|
||||
<q-card class="q-pa-md">
|
||||
<div class="row items-center q-mb-sm">
|
||||
<q-icon name="schedule"
|
||||
size="24px"
|
||||
class="q-mr-sm"/>
|
||||
<q-icon
|
||||
name="schedule"
|
||||
size="24px"
|
||||
class="q-mr-sm"
|
||||
/>
|
||||
<div class="text-h6">
|
||||
{{
|
||||
props.mode === 'create'
|
||||
? $t('timesheet.shift.actions.add')
|
||||
: props.mode === 'edit'
|
||||
? $t('timesheet.shift.actions.edit')
|
||||
: $t('timesheet.shift.actions.delete')
|
||||
{{
|
||||
mode === 'create'
|
||||
? $t('timesheet.shift.actions.add')
|
||||
: mode === 'update'
|
||||
? $t('timesheet.shift.actions.edit')
|
||||
: $t('timesheet.shift.actions.delete')
|
||||
}}
|
||||
</div>
|
||||
<q-space/>
|
||||
<q-badge outline color="primary">
|
||||
{{ props.dateIso }}
|
||||
<q-space />
|
||||
<q-badge
|
||||
outline
|
||||
color="primary"
|
||||
>
|
||||
{{ date_iso }}
|
||||
</q-badge>
|
||||
</div>
|
||||
|
||||
<q-separator spaced/>
|
||||
|
||||
<div v-if="props.mode !== 'delete'" class="column q-gutter-md">
|
||||
|
||||
<q-separator spaced />
|
||||
|
||||
<div
|
||||
v-if="mode !== 'delete'"
|
||||
class="column q-gutter-md"
|
||||
>
|
||||
<div class="row ">
|
||||
<div class="col">
|
||||
<q-input
|
||||
v-model="startTime"
|
||||
<q-input
|
||||
v-model="current_shift.start_time"
|
||||
:label="$t('timesheet.shift.fields.start')"
|
||||
filled dense
|
||||
filled
|
||||
dense
|
||||
inputmode="numeric"
|
||||
mask="##:##"
|
||||
/>
|
||||
</div>
|
||||
<div class="col">
|
||||
<q-input
|
||||
v-model="endTime"
|
||||
<q-input
|
||||
v-model="current_shift.end_time"
|
||||
:label="$t('timesheet.shift.fields.end')"
|
||||
filled dense
|
||||
inputmode="numeric"
|
||||
mask="##:##"
|
||||
filled
|
||||
dense
|
||||
inputmode="numeric"
|
||||
mask="##:##"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row items-center">
|
||||
<q-select
|
||||
v-model="type"
|
||||
v-model="current_shift.type"
|
||||
options-dense
|
||||
:options="props.shiftOptions"
|
||||
:options="SHIFT_TYPES"
|
||||
:label="$t('timesheet.shift.types.label')"
|
||||
class="col"
|
||||
color="primary"
|
||||
filled dense
|
||||
filled
|
||||
dense
|
||||
hide-dropdown-icon
|
||||
emit-value
|
||||
map-options
|
||||
/>
|
||||
<q-toggle
|
||||
v-model="isRemote"
|
||||
:label="$t('timesheet.shift.types.REMOTE')"
|
||||
class="col-auto" />
|
||||
<q-toggle
|
||||
v-model="current_shift.is_remote"
|
||||
:label="$t('timesheet.shift.types.REMOTE')"
|
||||
class="col-auto"
|
||||
/>
|
||||
</div>
|
||||
<q-input
|
||||
v-model="comment"
|
||||
type="textarea"
|
||||
autogrow filled dense
|
||||
:label="$t('timesheet.shift.fields.header_comment')"
|
||||
:counter="true" :maxlength="512"
|
||||
<q-input
|
||||
v-model="current_shift.comment"
|
||||
type="textarea"
|
||||
autogrow
|
||||
filled
|
||||
dense
|
||||
:label="$t('timesheet.shift.fields.header_comment')"
|
||||
:counter="true"
|
||||
:maxlength="512"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else class="q-pa-md">
|
||||
<div
|
||||
v-else
|
||||
class="q-pa-md"
|
||||
>
|
||||
{{ $t('timesheet.shift.actions.delete_confirmation_msg') }}
|
||||
</div>
|
||||
|
||||
<div v-if="errorBanner" class="q-mt-md">
|
||||
<q-banner dense class="bg-red-2 text-negative">{{ errorBanner }}</q-banner>
|
||||
<div v-if="conflicts.length" class="q-mt-xs">
|
||||
<div
|
||||
v-if="errorBanner"
|
||||
class="q-mt-md"
|
||||
>
|
||||
<q-banner
|
||||
dense
|
||||
class="bg-red-2 text-negative"
|
||||
>{{ errorBanner }}</q-banner>
|
||||
<div
|
||||
v-if="conflicts.length"
|
||||
class="q-mt-xs"
|
||||
>
|
||||
<div class="text-caption">Conflits :</div>
|
||||
<ul class="q-pl-md q-mt-xs">
|
||||
<li v-for="(c, i) in conflicts" :key="i">
|
||||
<li
|
||||
v-for="(c, i) in conflicts"
|
||||
:key="i"
|
||||
>
|
||||
{{ c.start_time }}–{{ c.end_time }} ({{ c.type }})
|
||||
</li>
|
||||
</ul>
|
||||
|
|
@ -218,28 +153,20 @@ watch(
|
|||
<q-separator spaced />
|
||||
|
||||
<div class="row justify-end q-gutter-sm">
|
||||
<q-btn
|
||||
flat
|
||||
color="grey-8"
|
||||
:label="$t('timesheet.cancel_button')"
|
||||
@click="() => { opened = false; emit('close');}"
|
||||
<q-btn
|
||||
flat
|
||||
color="grey-8"
|
||||
:label="$t('timesheet.cancel_button')"
|
||||
@click="close"
|
||||
/>
|
||||
<q-btn
|
||||
v-if="props.mode === 'delete'"
|
||||
outline color="negative"
|
||||
icon="cancel"
|
||||
:label="$t('timesheet.delete_button')"
|
||||
color="primary"
|
||||
icon="save_alt"
|
||||
:label="mode === 'delete' ? $t('timesheet.delete_button') : $t('timesheet.save_button')"
|
||||
:loading="isSubmitting"
|
||||
:disable="!canSubmit"
|
||||
@click="onSubmit"
|
||||
@click="upsertOrDeleteShiftByEmployeeEmail(employeeEmail)"
|
||||
/>
|
||||
<q-btn v-else
|
||||
color="primary"
|
||||
icon="save_alt"
|
||||
:label="$t('timesheet.save_button')"
|
||||
:loading="isSubmitting"
|
||||
:disable="!canSubmit"
|
||||
@click="onSubmit"/>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
|
|
|||
|
|
@ -1,23 +1,23 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import type { ShiftLegendItem } from '../../types/shift.types';
|
||||
import type { ShiftLegendItem } from 'src/modules/timesheets/models/shift.models';
|
||||
|
||||
const { t } = useI18n();
|
||||
const props = defineProps<{ isLoading: boolean; }>();
|
||||
|
||||
const legend: ShiftLegendItem[] = [
|
||||
{type:'REGULAR' , color: 'secondary', label_key: 'timesheet.shift.types.REGULAR', text_color: 'grey-8'},
|
||||
{type:'EVENING' , color: 'warning' , label_key: 'timesheet.shift.types.EVENING'},
|
||||
{type:'EMERGENCY', color: 'amber-10' , label_key: 'timesheet.shift.types.EMERGENCY'},
|
||||
{type:'OVERTIME' , color: 'negative' , label_key: 'timesheet.shift.types.OVERTIME'},
|
||||
{type:'VACATION' , color: 'purple-10', label_key: 'timesheet.shift.types.VACATION'},
|
||||
{type:'HOLIDAY' , color: 'purple-8' , label_key: 'timesheet.shift.types.HOLIDAY'},
|
||||
{type:'SICK' , color: 'grey-8' , label_key: 'timesheet.shift.types.SICK'},
|
||||
{type:'REGULAR' , color: 'secondary', label_type: 'timesheet.shift.types.REGULAR', text_color: 'grey-8'},
|
||||
{type:'EVENING' , color: 'warning' , label_type: 'timesheet.shift.types.EVENING'},
|
||||
{type:'EMERGENCY', color: 'amber-10' , label_type: 'timesheet.shift.types.EMERGENCY'},
|
||||
{type:'OVERTIME' , color: 'negative' , label_type: 'timesheet.shift.types.OVERTIME'},
|
||||
{type:'VACATION' , color: 'purple-10', label_type: 'timesheet.shift.types.VACATION'},
|
||||
{type:'HOLIDAY' , color: 'purple-8' , label_type: 'timesheet.shift.types.HOLIDAY'},
|
||||
{type:'SICK' , color: 'grey-8' , label_type: 'timesheet.shift.types.SICK'},
|
||||
]
|
||||
|
||||
const shift_type_legend = computed(()=>
|
||||
legend.map(item => ({ ...item, label: t(item.label_key)} ))
|
||||
legend.map(item => ({ ...item, label: t(item.label_type)} ))
|
||||
);
|
||||
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,85 +1,85 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import type { Shift } from '../../types/shift.interfaces';
|
||||
import { computed } from 'vue';
|
||||
import type { Shift } from 'src/modules/timesheets/models/shift.models';
|
||||
|
||||
/* eslint-disable */
|
||||
const props = defineProps<{
|
||||
|
||||
const { shift } = defineProps<{
|
||||
shift: Shift;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'save-comment' : [payload: { comment: string; shift: Shift }];
|
||||
'request-edit' : [payload: { shift: Shift }];
|
||||
'request-delete': [payload: { shift: Shift }];
|
||||
'save-comment': [comment: string, shift: Shift];
|
||||
'request-update': [shift: Shift];
|
||||
'request-delete': [shift: Shift];
|
||||
}>();
|
||||
|
||||
const has_comment = computed(()=> {
|
||||
const comment = (props.shift as any).description ?? (props.shift as any).comment ?? '';
|
||||
const has_comment = computed(() => {
|
||||
const comment = (shift as any).description ?? (shift as any).comment ?? '';
|
||||
return typeof comment === 'string' && comment.trim().length > 0;
|
||||
})
|
||||
const comment_icon = computed(()=> (has_comment.value ? 'announcement' : 'chat_bubble_outline'));
|
||||
const comment_color = computed(()=> (has_comment.value ? 'primary' : 'grey-8'));
|
||||
const comment_icon = computed(() => (has_comment.value ? 'announcement' : 'chat_bubble_outline'));
|
||||
const comment_color = computed(() => (has_comment.value ? 'primary' : 'grey-8'));
|
||||
|
||||
|
||||
const get_shift_color = (type: string): string => {
|
||||
switch(type) {
|
||||
case 'REGULAR': return 'secondary';
|
||||
case 'EVENING': return 'warning';
|
||||
case 'EMERGENCY': return 'amber-10';
|
||||
case 'OVERTIME': return 'negative';
|
||||
case 'VACATION': return 'purple-10';
|
||||
case 'HOLIDAY': return 'purple-10';
|
||||
case 'SICK': return 'grey-8';
|
||||
default : return 'transparent';
|
||||
switch (type) {
|
||||
case 'REGULAR': return 'secondary';
|
||||
case 'EVENING': return 'warning';
|
||||
case 'EMERGENCY': return 'amber-10';
|
||||
case 'OVERTIME': return 'negative';
|
||||
case 'VACATION': return 'purple-10';
|
||||
case 'HOLIDAY': return 'purple-10';
|
||||
case 'SICK': return 'grey-8';
|
||||
default: return 'transparent';
|
||||
}
|
||||
};
|
||||
|
||||
const get_text_color = (type: string): string => {
|
||||
switch(type) {
|
||||
switch (type) {
|
||||
case 'REGULAR': return 'grey-8';
|
||||
case '': return 'grey-5';
|
||||
default: return 'white';
|
||||
case '': return 'grey-5';
|
||||
default: return 'white';
|
||||
}
|
||||
}
|
||||
const on_click_edit = (type: string) => {
|
||||
if(type !== '') { emit('request-edit', { shift: props.shift })};
|
||||
}
|
||||
const on_click_delete = () => emit('request-delete', { shift: props.shift });
|
||||
|
||||
const onClickUpdate = (type: string) => {
|
||||
if (type !== '') { emit('request-update', shift) };
|
||||
}
|
||||
|
||||
const onClickDelete = () => emit('request-delete', shift);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-card-section
|
||||
<q-card-section
|
||||
horizontal
|
||||
class="q-pa-none text-uppercase text-center items-center rounded-10"
|
||||
:class="props.shift.type"
|
||||
:class="shift.type"
|
||||
style="line-height: 1;"
|
||||
@click.stop="on_click_edit(props.shift.type)"
|
||||
@click.stop="onClickUpdate(shift.type)"
|
||||
>
|
||||
<!-- punch-in timestamps -->
|
||||
<q-card-section class="q-pa-none col">
|
||||
<q-item-label
|
||||
<q-item-label
|
||||
class="text-weight-bolder q-pa-xs rounded-5"
|
||||
:class="'bg-' + get_shift_color(props.shift.type) + ' text-' + get_text_color(props.shift.type)"
|
||||
:class="'bg-' + get_shift_color(shift.type) + ' text-' + get_text_color(shift.type)"
|
||||
style="font-size: 1.5em; line-height: 80% !important;"
|
||||
>
|
||||
{{ props.shift.start_time }}
|
||||
{{ shift.start_time }}
|
||||
</q-item-label>
|
||||
</q-card-section>
|
||||
|
||||
<!-- arrows pointing to punch-out timestamps -->
|
||||
<q-card-section
|
||||
horizontal
|
||||
horizontal
|
||||
class="items-center justify-center q-mx-sm col"
|
||||
>
|
||||
<div
|
||||
<div
|
||||
v-for="icon_data, index in [
|
||||
{ transform: 'transform: translateX(5px);', color: 'accent' },
|
||||
{ transform: 'transform: translateX(5px);', color: 'accent' },
|
||||
{ transform: 'transform: translateX(-5px);', color: 'primary' }]"
|
||||
:key="index"
|
||||
>
|
||||
>
|
||||
<q-icon
|
||||
v-if="props.shift.type"
|
||||
v-if="shift.type"
|
||||
name="double_arrow"
|
||||
:color="icon_data.color"
|
||||
size="24px"
|
||||
|
|
@ -90,22 +90,20 @@ const on_click_delete = () => emit('request-delete', { shift: props.shift });
|
|||
|
||||
<!-- punch-out timestamps -->
|
||||
<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="'bg-' + get_shift_color(props.shift.type) + ' text-' + get_text_color(props.shift.type)"
|
||||
:class="'bg-' + get_shift_color(shift.type) + ' text-' + get_text_color(shift.type)"
|
||||
style="font-size: 1.5em; line-height: 80% !important;"
|
||||
>
|
||||
{{ props.shift.end_time }}
|
||||
{{ shift.end_time }}
|
||||
</q-item-label>
|
||||
</q-card-section>
|
||||
|
||||
<!-- comment and expenses buttons -->
|
||||
<q-card-section
|
||||
class="col q-pa-none text-right"
|
||||
>
|
||||
<q-card-section class="col q-pa-none text-right">
|
||||
<!-- comment btn -->
|
||||
<q-icon
|
||||
v-if="props.shift.type"
|
||||
v-if="shift.type"
|
||||
:name="comment_icon"
|
||||
:color="comment_color"
|
||||
class="q-pa-none q-mx-xs"
|
||||
|
|
@ -113,23 +111,23 @@ const on_click_delete = () => emit('request-delete', { shift: props.shift });
|
|||
/>
|
||||
<!-- expenses btn -->
|
||||
<q-btn
|
||||
v-if="props.shift.type"
|
||||
flat
|
||||
v-if="shift.type"
|
||||
flat
|
||||
dense
|
||||
color='grey-8'
|
||||
icon="attach_money"
|
||||
icon="attach_money"
|
||||
class="q-pa-none q-mx-xs"
|
||||
/>
|
||||
/>
|
||||
<!-- delete btn -->
|
||||
<q-btn
|
||||
v-if="props.shift.type"
|
||||
v-if="shift.type"
|
||||
push
|
||||
dense
|
||||
size="sm"
|
||||
color="red-6"
|
||||
icon="close"
|
||||
class="q-ml-xs"
|
||||
@click.stop="on_click_delete"
|
||||
@click.stop="onClickDelete"
|
||||
/>
|
||||
</q-card-section>
|
||||
</q-card-section>
|
||||
|
|
|
|||
|
|
@ -1,46 +1,49 @@
|
|||
<script setup lang="ts">
|
||||
import type { PayPeriod } from 'src/modules/shared/types/pay-period-interface';
|
||||
import detailedShiftListHeader from './detailed-shift-list-header.vue';
|
||||
import detailedShiftListRow from './detailed-shift-list-row.vue';
|
||||
import { date } from 'quasar';
|
||||
import type { TimesheetPayPeriodDetailsOverview } from '../../types/timesheet.interfaces';
|
||||
import type { Shift } from '../../types/shift.interfaces';
|
||||
import { default_shift } from '../../types/shift.defaults';
|
||||
<script
|
||||
setup
|
||||
lang="ts"
|
||||
>
|
||||
import { date } from 'quasar';
|
||||
import ShiftListHeader from 'src/modules/timesheets/components/shift/shift-list-header.vue';
|
||||
import ShiftListRow from 'src/modules/timesheets/components/shift/shift-list-row.vue';
|
||||
import ShiftListLegend from 'src/modules/timesheets/components/shift/shift-list-legend.vue';
|
||||
import PayPeriodNavigator from 'src/modules/shared/components/pay-period-navigator.vue';
|
||||
import { useShiftStore } from 'src/stores/shift-store';
|
||||
import { useTimesheetApi } from 'src/modules/timesheets/composables/api/use-timesheet-api';
|
||||
import { type Shift, default_shift } from 'src/modules/timesheets/models/shift.models';
|
||||
import type { PayPeriodDetails } from 'src/modules/timesheets/models/pay-period-details.models';
|
||||
import type { PayPeriod } from 'src/modules/shared/types/pay-period-interface';
|
||||
|
||||
const props = defineProps<{
|
||||
rawData: TimesheetPayPeriodDetailsOverview;
|
||||
rawData: PayPeriodDetails;
|
||||
currentPayPeriod: PayPeriod;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'request-add' : [payload: { date: string }];
|
||||
'request-edit' : [payload: { date: string; shift: Shift }];
|
||||
'request-delete' : [payload: { date: string; shift: Shift }];
|
||||
// 'save-comment' : [payload: { date: string; shift: Shift; comment: string }];
|
||||
}>();
|
||||
|
||||
const get_date_from_short = (short_date: string):
|
||||
Date => new Date(props.currentPayPeriod.pay_year.toString() + '/' + short_date);
|
||||
const to_iso_date = (short_date: string):
|
||||
string => date.formatDate(get_date_from_short(short_date), 'YYYY-MM-DD');
|
||||
const timesheet_api = useTimesheetApi();
|
||||
const { openCreate, openDelete, openUpdate } = useShiftStore();
|
||||
|
||||
const get_date_from_short = (short_date: string): Date => {
|
||||
return new Date(props.currentPayPeriod.pay_year.toString() + '/' + short_date);
|
||||
};
|
||||
|
||||
const to_iso_date = (short_date: string): string => {
|
||||
return date.formatDate(get_date_from_short(short_date), 'YYYY-MM-DD');
|
||||
};
|
||||
|
||||
const shifts_or_placeholder = (shifts: Shift[]): Shift[] => {
|
||||
return shifts.length > 0 ? shifts : [default_shift];
|
||||
};
|
||||
|
||||
const shifts_or_placeholder = (shifts: Shift[]):
|
||||
Shift[] => { return shifts.length > 0 ? shifts : [default_shift]; };
|
||||
|
||||
const getDate = (shift_date: string): Date => {
|
||||
return new Date(props.currentPayPeriod.pay_year.toString() + '/' + shift_date);
|
||||
};
|
||||
|
||||
const on_request_add = (iso_date: string) => emit('request-add', { date: iso_date });
|
||||
const on_request_edit = (iso_date: string, shift: Shift) => emit('request-edit', { date: iso_date, shift });
|
||||
const on_request_delete = (iso_date: string, shift: Shift) => emit('request-delete', { date: iso_date, shift });
|
||||
// const on_save_comment = (iso_date: string, shift: Shift, comment: string) => emit('save-comment', { date: iso_date, shift, comment });
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-for="week, index in props.rawData"
|
||||
<!-- shift's colored legend -->
|
||||
<ShiftListLegend :is-loading="false" />
|
||||
|
||||
<div
|
||||
v-for="week, index in props.rawData.weeks"
|
||||
:key="index"
|
||||
class="q-px-xs q-pt-xs rounded-5 col"
|
||||
>
|
||||
|
|
@ -51,35 +54,34 @@ import { default_shift } from '../../types/shift.defaults';
|
|||
bordered
|
||||
class="row items-center rounded-10 q-mb-xs"
|
||||
>
|
||||
|
||||
<!-- Dates column -->
|
||||
<q-card-section class="col-auto q-pa-xs text-white">
|
||||
<div
|
||||
class="bg-primary rounded-10 q-pa-xs text-center"
|
||||
>
|
||||
<q-item-label
|
||||
<div class="bg-primary rounded-10 q-pa-xs text-center">
|
||||
<q-item-label
|
||||
style="font-size: 0.7em;"
|
||||
class="text-uppercase"
|
||||
>{{ $d(getDate(day.short_date), {weekday: $q.screen.lt.md ? 'short' : 'long'}) }}</q-item-label>
|
||||
<q-item-label
|
||||
>{{ $d(getDate(day.short_date), { weekday: $q.screen.lt.md ? 'short' : 'long' }) }}</q-item-label>
|
||||
<q-item-label
|
||||
class="text-weight-bolder"
|
||||
style="font-size: 2.5em; line-height: 90% !important;"
|
||||
>{{ day.short_date.split('/')[1] }}</q-item-label>
|
||||
<q-item-label
|
||||
<q-item-label
|
||||
style="font-size: 0.7em;"
|
||||
class="text-uppercase"
|
||||
>{{ $d(getDate(day.short_date), {month: $q.screen.lt.md ? 'short' : 'long'}) }}</q-item-label>
|
||||
>{{ $d(getDate(day.short_date), { month: $q.screen.lt.md ? 'short' : 'long' }) }}</q-item-label>
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
<!-- List of shifts column -->
|
||||
<q-card-section class="col q-pa-none">
|
||||
<detailedShiftListHeader />
|
||||
<detailedShiftListRow
|
||||
<ShiftListHeader />
|
||||
<ShiftListRow
|
||||
v-for="shift, shift_index in shifts_or_placeholder(day.shifts)"
|
||||
:key="shift_index"
|
||||
:key="shift_index"
|
||||
:shift="shift"
|
||||
@request-edit=" ({ shift }) => on_request_edit(to_iso_date(day.short_date), shift )"
|
||||
@request-delete="({ shift }) => on_request_delete(to_iso_date(day.short_date), shift )"
|
||||
@request-update="value => openUpdate(to_iso_date(day.short_date), value)"
|
||||
@request-delete="value => openDelete(to_iso_date(day.short_date), value)"
|
||||
/>
|
||||
</q-card-section>
|
||||
<!-- add shift btn column -->
|
||||
|
|
@ -89,7 +91,7 @@ import { default_shift } from '../../types/shift.defaults';
|
|||
color="primary"
|
||||
icon="more_time"
|
||||
class="q-pa-sm"
|
||||
@click="on_request_add(to_iso_date(day.short_date))"
|
||||
@click="openCreate(to_iso_date(day.short_date))"
|
||||
/>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -1,81 +1,21 @@
|
|||
import { api } from "src/boot/axios";
|
||||
import { isProxy, toRaw } from "vue";
|
||||
import { useTimesheetStore } from "src/stores/timesheet-store";
|
||||
import { useExpenseItems } from "src/modules/timesheets/composables/use-expense-items";
|
||||
import { normalizeExpense, validateExpenseUI } from "../../utils/expenses-validators";
|
||||
import type { ExpenseType } from "../../types/expense.types";
|
||||
import { ExpensesApiError } from "../../types/expense-validation.interface";
|
||||
import type {
|
||||
ExpensePayload,
|
||||
PayPeriodExpenses,
|
||||
TimesheetExpense,
|
||||
UpsertExpensesBody,
|
||||
UpsertExpensesResponse
|
||||
} from "../../types/expense.interfaces";
|
||||
import type { ExpensesApiError } from "src/modules/timesheets/models/expense.validation";
|
||||
import type { Expense, ExpenseType, PayPeriodExpenses } from "src/modules/timesheets/models/expense.models";
|
||||
|
||||
/* eslint-disable */
|
||||
const toPlain = <T extends object>(obj:T): T => {
|
||||
const raw = isProxy(obj) ? toRaw(obj) : obj;
|
||||
if( typeof (globalThis as any).structuredClone === 'function') {
|
||||
return (globalThis as any).structuredClone(raw);
|
||||
}
|
||||
return JSON.parse(JSON.stringify(raw));
|
||||
};
|
||||
const { pay_period } = useTimesheetStore();
|
||||
const expense_items = useExpenseItems(draft);
|
||||
|
||||
const normalizePayload = (expense: ExpensePayload): ExpensePayload => {
|
||||
const exp = normalizeExpense(expense as unknown as TimesheetExpense);
|
||||
const out: ExpensePayload = {
|
||||
date: exp.date,
|
||||
type: exp.type as ExpenseType,
|
||||
comment: exp.comment || '',
|
||||
};
|
||||
if(typeof exp.amount === 'number') out.amount = exp.amount;
|
||||
if(typeof exp.mileage === 'number') out.mileage = exp.mileage;
|
||||
return out;
|
||||
}
|
||||
//PUT by employee_email, year and period no
|
||||
export const putPayPeriodExpensesByEmployeeEmail = async (employee_email: string, expenses: Expense[]): Promise<PayPeriodExpenses> => {
|
||||
const encoded_email = encodeURIComponent(employee_email);
|
||||
const encoded_year = encodeURIComponent(String(pay_period.pay_year));
|
||||
const encoded_pay_period_no = encodeURIComponent(String(pay_period.pay_period_no));
|
||||
|
||||
//GET by email, year and period no
|
||||
export const getPayPeriodExpenses = async (
|
||||
email: string,
|
||||
pay_year: number,
|
||||
pay_period_no: number
|
||||
) : Promise<PayPeriodExpenses> => {
|
||||
const encoded_email = encodeURIComponent(email);
|
||||
const encoded_year = encodeURIComponent(String(pay_year));
|
||||
const encoded_pay_period_no = encodeURIComponent(String(pay_period_no));
|
||||
const flat_expenses = expenses.map(expenses): [];
|
||||
|
||||
try {
|
||||
const { data } = await api.get<PayPeriodExpenses>(`/expenses/${encoded_email}/${encoded_year}/${encoded_pay_period_no}`);
|
||||
|
||||
const items = Array.isArray(data.expenses) ? data.expenses.map(normalizeExpense) : [];
|
||||
return {
|
||||
...data,
|
||||
expenses: items,
|
||||
};
|
||||
} catch(err:any) {
|
||||
const status_code: number = err?.response?.status ?? 500;
|
||||
const data = err?.response?.data ?? {};
|
||||
throw new ExpensesApiError({
|
||||
status_code,
|
||||
error_code: data.error_code,
|
||||
message: data.message || data.error || err.message,
|
||||
context: data.context,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
//PUT by email, year and period no
|
||||
export const putPayPeriodExpenses = async (
|
||||
email: string,
|
||||
pay_year: number,
|
||||
pay_period_no: number,
|
||||
expenses: TimesheetExpense[]
|
||||
): Promise<PayPeriodExpenses> => {
|
||||
const encoded_email = encodeURIComponent(email);
|
||||
const encoded_year = encodeURIComponent(String(pay_year));
|
||||
const encoded_pay_period_no = encodeURIComponent(String(pay_period_no));
|
||||
|
||||
const plain = Array.isArray(expenses) ? expenses.map(toPlain): [];
|
||||
|
||||
const normalized: ExpensePayload[] = plain.map((exp) => {
|
||||
const normalized: Expense[] = plain.map((exp) => {
|
||||
const norm = normalizeExpense(exp as TimesheetExpense);
|
||||
validateExpenseUI(norm, 'expense_item');
|
||||
return normalizePayload(norm as unknown as ExpensePayload);
|
||||
|
|
@ -85,10 +25,10 @@ export const putPayPeriodExpenses = async (
|
|||
|
||||
try {
|
||||
const { data } = await api.put<UpsertExpensesResponse>(
|
||||
`/expenses/${encoded_email}/${encoded_year}/${encoded_pay_period_no}`,
|
||||
body,
|
||||
{ headers: {'Content-Type': 'application/json'}}
|
||||
);
|
||||
// `/expenses/${encoded_email}/${encoded_year}/${encoded_pay_period_no}`,
|
||||
// body,
|
||||
// { headers: {'Content-Type': 'application/json'}}
|
||||
// );
|
||||
|
||||
const items = Array.isArray(data?.data?.expenses)
|
||||
? data.data.expenses.map(normalizeExpense)
|
||||
|
|
@ -97,7 +37,7 @@ export const putPayPeriodExpenses = async (
|
|||
...(data?.data ?? {
|
||||
pay_period_no,
|
||||
pay_year,
|
||||
employee_email: email,
|
||||
employee_email: employee_email,
|
||||
is_approved: false,
|
||||
expenses: [],
|
||||
totals: {amount: 0, mileage: 0},
|
||||
|
|
@ -117,12 +57,12 @@ export const putPayPeriodExpenses = async (
|
|||
};
|
||||
|
||||
export const postPayPeriodExpenses = async (
|
||||
email: string,
|
||||
employee_email: string,
|
||||
pay_year: number,
|
||||
pay_period_no: number,
|
||||
new_expenses: TimesheetExpense[]
|
||||
): Promise<PayPeriodExpenses> => {
|
||||
const encoded_email = encodeURIComponent(email);
|
||||
const encoded_email = encodeURIComponent(employee_email);
|
||||
const encoded_year = encodeURIComponent(String(pay_year));
|
||||
const encoded_pp = encodeURIComponent(String(pay_period_no));
|
||||
|
||||
|
|
@ -148,7 +88,7 @@ export const postPayPeriodExpenses = async (
|
|||
...(data?.data ?? {
|
||||
pay_period_no,
|
||||
pay_year,
|
||||
employee_email: email,
|
||||
employee_email: employee_email,
|
||||
is_approved: false,
|
||||
expenses: [],
|
||||
totals: { amount: 0, mileage: 0 },
|
||||
|
|
|
|||
|
|
@ -1,141 +1,85 @@
|
|||
import { api } from "src/boot/axios";
|
||||
import { isProxy, toRaw } from "vue";
|
||||
import { DATE_FORMAT_PATTERN, TIME_FORMAT_PATTERN } from "../../constants/shift.constants";
|
||||
import type { ShiftPayload } from "../../types/shift.types";
|
||||
import type { UpsertShiftsBody, UpsertShiftsResponse } from "../../types/shift.interfaces";
|
||||
/* eslint-disable */
|
||||
import { unwrapAndClone } from "src/utils/unwrap-and-clone";
|
||||
import { DATE_FORMAT_PATTERN, TIME_FORMAT_PATTERN } from "src/modules/timesheets/constants/shift.constants";
|
||||
import { GenericApiError } from "src/modules/timesheets/models/expense.validation";
|
||||
import { useShiftStore } from "src/stores/shift-store";
|
||||
import { default_shift, type Shift, type UpsertShift } from "src/modules/timesheets/models/shift.models";
|
||||
import { deepEqual } from "src/utils/deep-equal";
|
||||
|
||||
//normalize payload to match backend data
|
||||
export const normalize_comment = (input?: string): string | undefined => {
|
||||
if ( typeof input === 'undefined' || input === null) return undefined;
|
||||
const trimmed = String(input).trim();
|
||||
return trimmed.length ? trimmed : undefined;
|
||||
}
|
||||
export const useShiftApi = () => {
|
||||
const shift_store = useShiftStore();
|
||||
|
||||
export const normalize_payload = (payload: ShiftPayload): ShiftPayload => {
|
||||
const comment = normalize_comment(payload.comment);
|
||||
return {
|
||||
start_time: payload.start_time,
|
||||
end_time: payload.end_time,
|
||||
type: payload.type,
|
||||
is_remote: Boolean(payload.is_remote),
|
||||
...(comment !== undefined ? { comment } : {}),
|
||||
};
|
||||
};
|
||||
const normalizeShiftPayload = (shift: Shift): Shift => {
|
||||
const comment = shift.comment?.trim() || undefined;
|
||||
|
||||
const toPlain = <T extends object>(obj: T): T => {
|
||||
const raw = isProxy(obj) ? toRaw(obj): obj;
|
||||
if(typeof (globalThis as any).structuredClone === 'function') {
|
||||
return (globalThis as any).structuredClone(raw);
|
||||
}
|
||||
return JSON.parse(JSON.stringify(raw));
|
||||
}
|
||||
|
||||
//error handling
|
||||
export interface ApiErrorPayload {
|
||||
status_code: number;
|
||||
error_code?: string;
|
||||
message?: string;
|
||||
context?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class UpsertShiftsError extends Error {
|
||||
status_code: number;
|
||||
error_code?: string | undefined;
|
||||
context?: Record<string, unknown> | undefined;
|
||||
constructor(payload: ApiErrorPayload) {
|
||||
super(payload.message || 'Request failed');
|
||||
this.name = 'UpsertShiftsError';
|
||||
this.status_code = payload.status_code;
|
||||
this.error_code = payload.error_code;
|
||||
this.context = payload.context;
|
||||
}
|
||||
}
|
||||
|
||||
const parseHHMM = (s:string): [number, number] => {
|
||||
const m = /^(\d{2}):(\d{2})$/.exec(s);
|
||||
if(!m) {
|
||||
throw new UpsertShiftsError({ status_code: 400, message: `Invalid time format: ${s}. Expected HH:MM.`});
|
||||
}
|
||||
|
||||
const h = Number(m[1]);
|
||||
const min = Number(m[2]);
|
||||
|
||||
if(Number.isNaN(h) || Number.isNaN(min) || h < 0 || h> 23 || min < 0 || min > 59) {
|
||||
throw new UpsertShiftsError({ status_code: 400, message: `Invalid time value: ${s}.`})
|
||||
}
|
||||
return [h, min];
|
||||
}
|
||||
|
||||
const toMinutes = (hhmm: string): number => {
|
||||
const [h,m] = parseHHMM(hhmm);
|
||||
return h * 60 + m;
|
||||
}
|
||||
|
||||
const validateShift = (payload: ShiftPayload, label: 'old_shift'|'new_shift') => {
|
||||
if(!TIME_FORMAT_PATTERN.test(payload.start_time) || !TIME_FORMAT_PATTERN.test(payload.end_time)) {
|
||||
throw new UpsertShiftsError({
|
||||
status_code: 400,
|
||||
message: `Invalid time format in ${label}. Expected HH:MM`,
|
||||
context: { [label]: payload }
|
||||
});
|
||||
}
|
||||
|
||||
if(toMinutes(payload.end_time) <= toMinutes(payload.start_time)) {
|
||||
throw new UpsertShiftsError({
|
||||
status_code: 400,
|
||||
message: `Invalid time range in ${label}. The End time must be after the Start time`,
|
||||
context: { [label]: payload}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const upsertShiftsByDate = async (
|
||||
email: string,
|
||||
date: string,
|
||||
body: UpsertShiftsBody,
|
||||
): Promise<UpsertShiftsResponse> => {
|
||||
|
||||
if (!DATE_FORMAT_PATTERN.test(date)){
|
||||
throw new UpsertShiftsError({
|
||||
status_code: 400,
|
||||
message: 'Invalid date format, expected YYYY-MM-DD',
|
||||
});
|
||||
}
|
||||
|
||||
const flatBody: UpsertShiftsBody = {
|
||||
...(body.old_shift ? { old_shift: toPlain(body.old_shift) }: {}),
|
||||
...(body.new_shift ? { new_shift: toPlain(body.new_shift) }: {}),
|
||||
};
|
||||
|
||||
const normalized: UpsertShiftsBody = {
|
||||
...(flatBody.old_shift ? { old_shift: normalize_payload(flatBody.old_shift) } : {}),
|
||||
...(flatBody.new_shift ? { new_shift: normalize_payload(flatBody.new_shift) } : {}),
|
||||
};
|
||||
|
||||
if(normalized.old_shift) validateShift(normalized.old_shift, 'old_shift');
|
||||
if(normalized.new_shift) validateShift(normalized.new_shift, 'new_shift');
|
||||
|
||||
const encoded_email = encodeURIComponent(email);
|
||||
const encoded_date = encodeURIComponent(date);
|
||||
|
||||
//error handling to be used with notify in case of bad input
|
||||
try {
|
||||
const { data } = await api.put<UpsertShiftsResponse>(
|
||||
`/shifts/upsert/${encoded_email}/${encoded_date}`,
|
||||
normalized,
|
||||
{ headers: {'content-type': 'application/json'}}
|
||||
);
|
||||
return data;
|
||||
} catch (err: any) {
|
||||
const status_code: number = err?.response?.status ?? 500;
|
||||
const data = err?.response?.data ?? {};
|
||||
const payload: ApiErrorPayload = {
|
||||
status_code,
|
||||
error_code: data.error_code,
|
||||
message: data.message || data.error || err.message,
|
||||
context: data.context,
|
||||
return {
|
||||
date: shift.date,
|
||||
start_time: shift.start_time,
|
||||
end_time: shift.end_time,
|
||||
type: shift.type,
|
||||
is_approved: false,
|
||||
is_remote: shift.is_remote,
|
||||
comment: comment,
|
||||
};
|
||||
throw new UpsertShiftsError(payload);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const parseHHMM = (s: string): [number, number] => {
|
||||
const m = /^(\d{2}):(\d{2})$/.exec(s);
|
||||
|
||||
if (!m) {
|
||||
throw new GenericApiError({ status_code: 400, message: `Invalid time format: ${s}. Expected HH:MM.` });
|
||||
}
|
||||
|
||||
const h = Number(m[1]);
|
||||
const min = Number(m[2]);
|
||||
|
||||
if (Number.isNaN(h) || Number.isNaN(min) || h < 0 || h > 23 || min < 0 || min > 59) {
|
||||
throw new GenericApiError({ status_code: 400, message: `Invalid time value: ${s}.` })
|
||||
}
|
||||
return [h, min];
|
||||
};
|
||||
|
||||
const toMinutes = (hhmm: string): number => {
|
||||
const [h, m] = parseHHMM(hhmm);
|
||||
return h * 60 + m;
|
||||
};
|
||||
|
||||
const validateShift = (shift: Shift, label: 'old_shift' | 'new_shift') => {
|
||||
if (!TIME_FORMAT_PATTERN.test(shift.start_time) || !TIME_FORMAT_PATTERN.test(shift.end_time)) {
|
||||
throw new GenericApiError({
|
||||
status_code: 400,
|
||||
message: `Invalid time format in ${label}. Expected HH:MM`,
|
||||
context: { [label]: shift }
|
||||
});
|
||||
}
|
||||
|
||||
if (toMinutes(shift.end_time) <= toMinutes(shift.start_time)) {
|
||||
throw new GenericApiError({
|
||||
status_code: 400,
|
||||
message: `Invalid time range in ${label}. The End time must be after the Start time`,
|
||||
context: { [label]: shift }
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const upsertOrDeleteShiftByEmployeeEmail = async (employee_email: string): Promise<void> => {
|
||||
const flat_upsert_shift: UpsertShift = {
|
||||
...(deepEqual(shift_store.initial_shift, default_shift) ? { old_shift: unwrapAndClone(shift_store.initial_shift) } : {}),
|
||||
...(deepEqual(shift_store.current_shift, default_shift) ? { new_shift: unwrapAndClone(shift_store.current_shift) } : {}),
|
||||
};
|
||||
|
||||
const normalized_upsert_shift: UpsertShift = {
|
||||
...(flat_upsert_shift.old_shift ? { old_shift: normalizeShiftPayload(flat_upsert_shift.old_shift) } : {}),
|
||||
...(flat_upsert_shift.new_shift ? { new_shift: normalizeShiftPayload(flat_upsert_shift.new_shift) } : {}),
|
||||
};
|
||||
|
||||
if (normalized_upsert_shift.old_shift) validateShift(normalized_upsert_shift.old_shift, 'old_shift');
|
||||
if (normalized_upsert_shift.new_shift) validateShift(normalized_upsert_shift.new_shift, 'new_shift');
|
||||
|
||||
await shift_store.upsertOrDeleteShiftByEmployeeEmail(employee_email, normalized_upsert_shift);
|
||||
};
|
||||
|
||||
return {
|
||||
upsertOrDeleteShiftByEmployeeEmail,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,58 +1,53 @@
|
|||
import { useAuthStore } from "src/stores/auth-store";
|
||||
import { useTimesheetStore } from "src/stores/timesheet-store"
|
||||
/* eslint-disable */
|
||||
|
||||
export const useTimesheetApi = () => {
|
||||
const timesheet_store = useTimesheetStore();
|
||||
const auth_store = useAuthStore();
|
||||
const NEXT = 1;
|
||||
const PREVIOUS = -1;
|
||||
|
||||
const getPayPeriodDetailsByDate = async (date_string: string, employee_email?: string) => {
|
||||
const success = await timesheet_store.getPayPeriodByDateOrYearAndNumber(date_string);
|
||||
|
||||
const getTimesheetsByDate = async (date_string: string) => {
|
||||
const success = await timesheet_store.getPayPeriodByDate(date_string);
|
||||
|
||||
if (success) {
|
||||
await timesheet_store.getTimesheetsByPayPeriodAndEmail(auth_store.user.email)
|
||||
await timesheet_store.getPayPeriodDetailsByEmployeeEmail(employee_email ?? auth_store.user.email)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchPayPeriod = async (direction: number) => {
|
||||
const current_pay_period = timesheet_store.current_pay_period;
|
||||
let new_pay_period_no = current_pay_period.pay_period_no + direction;
|
||||
let new_pay_year = current_pay_period.pay_year;
|
||||
const getNextOrPreviousPayPeriodDetails = async (direction: number, employee_email?: string) => {
|
||||
const { pay_period } = timesheet_store;
|
||||
let new_number = pay_period.pay_period_no + direction;
|
||||
let new_year = pay_period.pay_year;
|
||||
|
||||
if (new_pay_period_no > 26) {
|
||||
new_pay_period_no = 1;
|
||||
new_pay_year += 1;
|
||||
if (new_number > 26) {
|
||||
new_number = 1;
|
||||
new_year += 1;
|
||||
}
|
||||
|
||||
if (new_pay_period_no < 1) {
|
||||
new_pay_period_no = 26;
|
||||
new_pay_year -= 1;
|
||||
if (new_number < 1) {
|
||||
new_number = 26;
|
||||
new_year -= 1;
|
||||
}
|
||||
|
||||
const success = await timesheet_store.getPayPeriodByYearAndPeriodNumber(new_pay_year, new_pay_period_no);
|
||||
|
||||
const success = await timesheet_store.getPayPeriodByDateOrYearAndNumber(new_year, new_number);
|
||||
|
||||
if (success) {
|
||||
await timesheet_store.getTimesheetsByPayPeriodAndEmail(auth_store.user.email);
|
||||
await timesheet_store.getPayPeriodDetailsByEmployeeEmail(employee_email ?? auth_store.user.email);
|
||||
}
|
||||
};
|
||||
|
||||
const getNextPayPeriodDetails = async (employee_email?: string) => {
|
||||
await getNextOrPreviousPayPeriodDetails(NEXT, employee_email ?? auth_store.user.email);
|
||||
}
|
||||
|
||||
const getNextPayPeriod = async () => fetchPayPeriod(1);
|
||||
const getPreviousPayPeriod = async () => fetchPayPeriod(-1);
|
||||
const getPreviousPayPeriodDetails = async (employee_email?: string) => {
|
||||
await getNextOrPreviousPayPeriodDetails(PREVIOUS, employee_email ?? auth_store.user.email);
|
||||
}
|
||||
|
||||
const getPreviousPeriodForUser = async (_employee_email: string) => {
|
||||
await getPreviousPayPeriod();
|
||||
};
|
||||
|
||||
const getNextPeriodForUser = async (_employee_email: string) => {
|
||||
await getNextPayPeriod();
|
||||
};
|
||||
|
||||
return {
|
||||
getTimesheetsByDate,
|
||||
fetchPayPeriod,
|
||||
// getCurrentPayPeriod,
|
||||
getNextPayPeriod,
|
||||
getPreviousPayPeriod,
|
||||
getPreviousPeriodForUser,
|
||||
getNextPeriodForUser,
|
||||
return {
|
||||
getPayPeriodDetailsByDate,
|
||||
getNextPayPeriodDetails,
|
||||
getPreviousPayPeriodDetails,
|
||||
};
|
||||
};
|
||||
|
|
@ -1,52 +1,55 @@
|
|||
import { ref, type Ref } from "vue";
|
||||
import { normalizeExpense, validateExpenseUI } from "../utils/expenses-validators";
|
||||
import { normExpenseType } from "../utils/expense.util";
|
||||
import type { TimesheetExpense } from "../types/expense.interfaces";
|
||||
import type { Expense, PayPeriodExpenses } from "src/modules/timesheets/models/expense.models";
|
||||
import { useExpensesStore } from "src/stores/expense-store";
|
||||
import { unwrapAndClone } from "src/utils/unwrap-and-clone";
|
||||
|
||||
type UseExpenseItemsParams = {
|
||||
initial_expenses?: TimesheetExpense[] | null | undefined;
|
||||
draft: Ref<Partial<TimesheetExpense>>;
|
||||
is_approved: Ref<boolean> | boolean;
|
||||
};
|
||||
export const useExpenseItems = ({
|
||||
initial_expenses,
|
||||
draft,
|
||||
is_approved
|
||||
}: UseExpenseItemsParams) => {
|
||||
const items = ref<TimesheetExpense[]>(
|
||||
Array.isArray(initial_expenses) ? initial_expenses.map(normalizeExpense) : []
|
||||
);
|
||||
const expenses_store = useExpensesStore();
|
||||
|
||||
export const useExpenseItems = () => {
|
||||
let expenses = unwrapAndClone(expenses_store.pay_period_expenses.expenses.map(normalizeExpense));
|
||||
|
||||
const normalizePayload = (expense: Expense): Expense => {
|
||||
const exp = normalizeExpense(expense);
|
||||
const out: Expense = {
|
||||
date: exp.date,
|
||||
type: exp.type as ExpenseType,
|
||||
comment: exp.comment || '',
|
||||
};
|
||||
if(typeof exp.amount === 'number') out.amount = exp.amount;
|
||||
if(typeof exp.mileage === 'number') out.mileage = exp.mileage;
|
||||
return out;
|
||||
}
|
||||
|
||||
const addFromDraft = () => {
|
||||
const candidate: TimesheetExpense = normalizeExpense({
|
||||
date: draft.value.date,
|
||||
type: normExpenseType(draft.value.type),
|
||||
...(typeof draft.value.amount === 'number' ? { amount: draft.value.amount }: {}),
|
||||
...(typeof draft.value.mileage === 'number' ? { mileage: draft.value.mileage }: {}),
|
||||
comment: String(draft.value.comment ?? '').trim(),
|
||||
} as TimesheetExpense);
|
||||
const candidate: Expense = normalizeExpense({
|
||||
date: draft.date,
|
||||
type: normExpenseType(draft.type),
|
||||
...(typeof draft.amount === 'number' ? { amount: draft.amount }: {}),
|
||||
...(typeof draft.mileage === 'number' ? { mileage: draft.mileage }: {}),
|
||||
comment: String(draft.comment ?? '').trim(),
|
||||
} as Expense);
|
||||
|
||||
validateExpenseUI(candidate, 'expense_draft');
|
||||
items.value = [ ...items.value, candidate];
|
||||
expenses = [ ...expenses, candidate];
|
||||
};
|
||||
|
||||
const removeAt = (index: number) => {
|
||||
const locked = typeof is_approved === 'boolean' ? is_approved : is_approved.value;
|
||||
if(locked) return;
|
||||
if(index < 0 || index >= items.value.length) return;
|
||||
items.value = items.value.filter((_,i)=> i !== index);
|
||||
if(index < 0 || index >= expenses.length) return;
|
||||
expenses = expenses.filter((_,i)=> i !== index);
|
||||
};
|
||||
|
||||
const validateAll = () => {
|
||||
for (const expense of items.value) {
|
||||
for (const expense of expenses) {
|
||||
validateExpenseUI(expense, 'expense_item');
|
||||
}
|
||||
};
|
||||
|
||||
const payload = () => items.value.map(normalizeExpense);
|
||||
const payload = () => expenses.map(normalizeExpense);
|
||||
|
||||
return {
|
||||
items,
|
||||
expenses,
|
||||
addFromDraft,
|
||||
removeAt,
|
||||
validateAll,
|
||||
|
|
|
|||
|
|
@ -1,34 +0,0 @@
|
|||
export interface ApiErrorPayload {
|
||||
status_code: number;
|
||||
error_code?: string;
|
||||
message?: string;
|
||||
context?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class ExpensesValidationError extends Error {
|
||||
status_code: number;
|
||||
error_code?: string | undefined;
|
||||
context?: Record<string, unknown> | undefined;
|
||||
|
||||
constructor(payload: ApiErrorPayload) {
|
||||
super(payload.message || 'Invalid expense payload');
|
||||
this.name = 'ExpensesValidationError';
|
||||
this.status_code = payload.status_code;
|
||||
this.error_code = payload.error_code;
|
||||
this.context = payload.context;
|
||||
}
|
||||
}
|
||||
|
||||
export class ExpensesApiError extends Error {
|
||||
status_code: number;
|
||||
error_code?: string;
|
||||
context?: Record<string, unknown>;
|
||||
constructor(payload: ApiErrorPayload) {
|
||||
super(payload.message || 'Request failed');
|
||||
this.name = 'ExpensesApiError';
|
||||
this.status_code = payload.status_code;
|
||||
|
||||
if(payload.error_code !== undefined) this.error_code = payload.error_code;
|
||||
if(payload.context !== undefined) this.context = payload.context;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,78 +1,46 @@
|
|||
// export const EXPENSE_TYPE = [
|
||||
// 'PER_DIEM',
|
||||
// 'MILEAGE',
|
||||
// 'EXPENSES',
|
||||
// 'PRIME_GARDE',
|
||||
// ] as const;
|
||||
|
||||
// export type ExpenseType = (typeof EXPENSE_TYPE)[number];
|
||||
|
||||
// export const TYPES_WITH_MILEAGE_ONLY: Readonly<ExpenseType[]> = ['MILEAGE'];
|
||||
|
||||
// export const TYPES_WITH_AMOUNT_ONLY: Readonly<ExpenseType[]> = [
|
||||
// 'PER_DIEM',
|
||||
// 'EXPENSES',
|
||||
// 'PRIME_GARDE',
|
||||
// ];
|
||||
|
||||
export type ExpenseType = 'PER_DIEM' | 'MILEAGE' | 'EXPENSES' | 'PRIME_GARDE';
|
||||
|
||||
export type ExpenseTotals = {
|
||||
amount: number;
|
||||
mileage: number;
|
||||
};
|
||||
|
||||
// export type ExpenseSavePayload = {
|
||||
// pay_period_no: number;
|
||||
// pay_year: number;
|
||||
// email: string;
|
||||
// expenses: TimesheetExpense[];
|
||||
// };
|
||||
export const EXPENSE_TYPE: ExpenseType[] = ['PER_DIEM', 'MILEAGE', 'EXPENSES', 'PRIME_GARDE',];
|
||||
export const TYPES_WITH_MILEAGE_ONLY: Readonly<ExpenseType[]> = ['MILEAGE'];
|
||||
export const TYPES_WITH_AMOUNT_ONLY: Readonly<ExpenseType[]> = ['PER_DIEM', 'EXPENSES', 'PRIME_GARDE',];
|
||||
|
||||
export interface Expense {
|
||||
// is_approved: boolean;
|
||||
// comment: string;
|
||||
// amount: number;
|
||||
// supervisor_comment: string;
|
||||
// }
|
||||
|
||||
// export interface TimesheetExpense {
|
||||
date: string;
|
||||
type: string;
|
||||
amount?: number;
|
||||
mileage?: number;
|
||||
comment?: string;
|
||||
date: string;
|
||||
type: ExpenseType;
|
||||
amount?: number;
|
||||
mileage?: number;
|
||||
comment: string;
|
||||
supervisor_comment?: string;
|
||||
is_approved?: boolean;
|
||||
is_approved: boolean;
|
||||
}
|
||||
|
||||
// export interface PayPeriodExpenses {
|
||||
export interface TimesheetExpenses {
|
||||
pay_period_no: number;
|
||||
pay_year: number;
|
||||
employee_email: string;
|
||||
is_approved: boolean;
|
||||
// expenses: TimesheetExpense[];
|
||||
export type ExpenseTotals = {
|
||||
amount: number;
|
||||
mileage: number;
|
||||
reimburseable_total?: number;
|
||||
};
|
||||
|
||||
export interface PayPeriodExpenses {
|
||||
is_approved: boolean;
|
||||
expenses: Expense[];
|
||||
totals?: {
|
||||
amount: number;
|
||||
mileage: number;
|
||||
reimbursable_total?: number;
|
||||
}
|
||||
totals?: ExpenseTotals;
|
||||
}
|
||||
|
||||
// export interface ExpensePayload{
|
||||
// date: string;
|
||||
// type: ExpenseType;
|
||||
// amount?: number;
|
||||
// mileage?: number;
|
||||
// comment: string;
|
||||
// }
|
||||
export interface TimesheetDetailsWeekDayExpenses {
|
||||
cash: Expense[];
|
||||
km: Expense[];
|
||||
[otherType: string]: Expense[];
|
||||
}
|
||||
|
||||
// export interface UpsertExpensesBody {
|
||||
// expenses: ExpensePayload[];
|
||||
// }
|
||||
export const default_expense: Expense = {
|
||||
date: '',
|
||||
type: 'EXPENSES',
|
||||
amount: 0,
|
||||
comment: '',
|
||||
is_approved: false,
|
||||
};
|
||||
|
||||
// export interface UpsertExpensesResponse {
|
||||
// data: PayPeriodExpenses;
|
||||
// }
|
||||
export const default_pay_period_expenses: PayPeriodExpenses = {
|
||||
is_approved: false,
|
||||
expenses: [],
|
||||
}
|
||||
77
src/modules/timesheets/models/expense.validation.ts
Normal file
77
src/modules/timesheets/models/expense.validation.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { 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 }
|
||||
});
|
||||
}
|
||||
77
src/modules/timesheets/models/pay-period-details.models.ts
Normal file
77
src/modules/timesheets/models/pay-period-details.models.ts
Normal 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: "",
|
||||
}
|
||||
|
|
@ -1,82 +1,49 @@
|
|||
// export const SHIFT_KEY = [
|
||||
// 'REGULAR',
|
||||
// 'EVENING',
|
||||
// 'EMERGENCY',
|
||||
// 'HOLIDAY',
|
||||
// 'VACATION',
|
||||
// 'SICK'
|
||||
// ] as const;
|
||||
export const SHIFT_TYPES = [
|
||||
'REGULAR',
|
||||
'EVENING',
|
||||
'EMERGENCY',
|
||||
'OVERTIME',
|
||||
'HOLIDAY',
|
||||
'VACATION',
|
||||
'SICK'
|
||||
];
|
||||
|
||||
// export type ShiftKey = typeof SHIFT_KEY[number];
|
||||
export type ShiftType = 'REGULAR' | 'EVENING' | 'EMERGENCY' | 'OVERTIME' | 'HOLIDAY' | 'VACATION' | 'SICK' ;
|
||||
|
||||
// export type ShiftSelectOption = { value: ShiftKey; label: string };
|
||||
|
||||
// export type ShiftPayload = {
|
||||
// start_time: string;
|
||||
// end_time: string;
|
||||
// type: ShiftKey;
|
||||
// is_remote: boolean;
|
||||
// comment?: string;
|
||||
// }
|
||||
|
||||
export type ShiftKey = 'REGULAR' | 'EVENING' | 'EMERGENCY' | 'HOLIDAY' | 'VACATION' | 'SICK';
|
||||
|
||||
export type UpsertAction = 'created' | 'updated' | 'deleted';
|
||||
export type UpsertAction = 'create' | 'update' | 'delete';
|
||||
|
||||
export type ShiftLegendItem = {
|
||||
type: ShiftKey;
|
||||
type: ShiftType;
|
||||
color: string;
|
||||
label_key: string;
|
||||
label_type: string;
|
||||
text_color?: string;
|
||||
};
|
||||
|
||||
export interface Shift {
|
||||
date: string;
|
||||
type: ShiftKey;
|
||||
type: ShiftType;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
comment: string;
|
||||
comment: string | undefined;
|
||||
is_approved: boolean;
|
||||
is_remote: boolean;
|
||||
}
|
||||
|
||||
export interface UpsertShiftsResponse {
|
||||
action: UpsertAction;
|
||||
// day: DayShift[];
|
||||
day: Shift[];
|
||||
}
|
||||
|
||||
// export interface CreateShiftPayload {
|
||||
// date: string;
|
||||
// type: ShiftKey;
|
||||
// start_time: string;
|
||||
// end_time: string;
|
||||
// comment?: string;
|
||||
// is_remote?: boolean;
|
||||
// }
|
||||
|
||||
// export interface CreateWeekShiftPayload {
|
||||
// shifts: CreateShiftPayload[];
|
||||
// }
|
||||
|
||||
// export interface UpsertShiftsBody {
|
||||
// old_shift?: ShiftPayload;
|
||||
// new_shift?: ShiftPayload;
|
||||
// }
|
||||
|
||||
// export interface DayShift {
|
||||
// start_time: string;
|
||||
// end_time: string;
|
||||
// type: string;
|
||||
// is_remote: boolean;
|
||||
// comment?: string | null;
|
||||
// }
|
||||
export interface UpsertShift {
|
||||
old_shift?: Shift | undefined;
|
||||
new_shift?: Shift | undefined;
|
||||
}
|
||||
|
||||
export const default_shift: Readonly<Shift> = {
|
||||
date: '',
|
||||
start_time: '--:--',
|
||||
end_time: '--:--',
|
||||
type:'REGULAR',
|
||||
type: 'REGULAR',
|
||||
comment: '',
|
||||
is_approved: false,
|
||||
is_remote: false,
|
||||
|
|
|
|||
|
|
@ -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: "",
|
||||
}
|
||||
|
|
@ -1,5 +1,3 @@
|
|||
export type FormMode = 'create' | 'edit' | 'delete';
|
||||
|
||||
export type PayPeriodLabel = {
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
|
|
@ -1,182 +1,48 @@
|
|||
<script setup lang="ts">
|
||||
import { date } from 'quasar';
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAuthStore } from 'src/stores/auth-store';
|
||||
import { useExpensesStore } from 'src/stores/expense-store';
|
||||
import { useShiftStore } from 'src/stores/shift-store';
|
||||
import { useTimesheetStore } from 'src/stores/timesheet-store';
|
||||
import { useTimesheetApi } from '../composables/api/use-timesheet-api';
|
||||
import { buildShiftOptions } from '../utils/shift.util';
|
||||
import { formatPayPeriodLabel } from '../utils/timesheet-format.util';
|
||||
import TimesheetNavigation from '../components/timesheet/timesheet-navigation.vue';
|
||||
import ShiftsLegend from '../components/shift/shifts-legend.vue';
|
||||
import ShiftCrudDialog from '../components/shift/shift-crud-dialog.vue';
|
||||
import TimesheetDetailsExpenses from '../components/expenses/timesheet-details-expenses.vue';
|
||||
import DetailedShiftList from '../components/shift/detailed-shift-list.vue';
|
||||
import type { ShiftKey } from '../models/shift.models';
|
||||
import type { TimesheetExpense } from '../types/expense.interfaces';
|
||||
/* eslint-disable */
|
||||
<script
|
||||
setup
|
||||
lang="ts"
|
||||
>
|
||||
import { date } from 'quasar';
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAuthStore } from 'src/stores/auth-store';
|
||||
import { useTimesheetStore } from 'src/stores/timesheet-store';
|
||||
import { useTimesheetApi } from '../composables/api/use-timesheet-api';
|
||||
import { formatPayPeriodLabel } from '../utils/timesheet-format.util';
|
||||
import PageHeaderTemplate from 'src/modules/shared/components/page-header-template.vue';
|
||||
|
||||
//------------------- stores -------------------
|
||||
const { locale, t } = useI18n();
|
||||
const auth_store = useAuthStore();
|
||||
const expenses_store = useExpensesStore();
|
||||
const shift_store = useShiftStore();
|
||||
const timesheet_store = useTimesheetStore();
|
||||
const timesheet_api = useTimesheetApi();
|
||||
//------------------- stores -------------------
|
||||
const { locale } = useI18n();
|
||||
const auth_store = useAuthStore();
|
||||
const timesheet_api = useTimesheetApi();
|
||||
|
||||
//------------------- expenses -------------------
|
||||
const openExpensesDialog = () => expenses_store.openDialog({
|
||||
email: auth_store.user.email,
|
||||
pay_year: timesheet_store.pay_period.pay_year,
|
||||
pay_period_no: timesheet_store.pay_period.pay_period_no,
|
||||
t,
|
||||
});
|
||||
//------------------- pay-period format label -------------------
|
||||
const date_options: Intl.DateTimeFormatOptions = { day: 'numeric', month: 'long', year: 'numeric' };
|
||||
|
||||
const onSaveExpenses = async ( payload: { email: string; pay_year: number; pay_period_no: number; expenses: TimesheetExpense[] }) => {
|
||||
await expenses_store.saveExpenses({...payload, t});
|
||||
};
|
||||
|
||||
const onCloseExpenses = () => expenses_store.closeDialog();
|
||||
|
||||
//------------------- pay-period format label -------------------
|
||||
const date_options: Intl.DateTimeFormatOptions = { day: 'numeric', month: 'long', year: 'numeric' };
|
||||
|
||||
const pay_period_label = computed(() => formatPayPeriodLabel(
|
||||
timesheet_store.pay_period.label,
|
||||
const pay_period_label = computed(() => formatPayPeriodLabel(
|
||||
pay_period.label,
|
||||
locale.value,
|
||||
date.extractDate,
|
||||
date_options
|
||||
)
|
||||
);
|
||||
);
|
||||
|
||||
//------------------- q-select Shift options -------------------
|
||||
const shift_options = computed(() => buildShiftOptions(SHIFT_KEY, t));
|
||||
|
||||
onMounted(async () => {
|
||||
await timesheet_store.loadToday(auth_store.user.email);
|
||||
});
|
||||
|
||||
// ------------------- shifts -------------------
|
||||
const onRequestAdd = ({ date }: { date: string }) => shift_store.openCreate(date);
|
||||
const onRequestEdit = ({ date, shift }: { date: string; shift: any }) => shift_store.openEdit(date, shift);
|
||||
const onRequestDelete = async ({ date, shift }: { date: string; shift: any }) => shift_store.openDelete(date, shift);
|
||||
const onShiftSaved = async () => {
|
||||
await timesheet_store.refreshCurrentPeriodForUser(auth_store.user.email);
|
||||
};
|
||||
onMounted(async () => {
|
||||
await timesheet_api.getPayPeriodDetailsByDate(date.formatDate(new Date(), 'YYYY-MM-DD'));
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<q-page padding class="q-pa-md bg-secondary" >
|
||||
<!-- title and dates -->
|
||||
<div class="text-h4 row justify-center text-center q-mt-lg text-uppercase text-weight-bolder text-grey-8">
|
||||
{{ $t('timesheet.title') }}
|
||||
</div>
|
||||
<div class="row items-center justify-center q-py-none q-my-none">
|
||||
<div
|
||||
class="text-primary text-uppercase text-weight-bold"
|
||||
:class="$q.screen.lt.md ? '' : 'text-h6'"
|
||||
>
|
||||
{{ pay_period_label.start_date }}
|
||||
</div>
|
||||
<div
|
||||
class="text-grey-8 text-uppercase q-mx-md"
|
||||
:class="$q.screen.lt.md ? 'text-weight-medium text-caption' : 'text-weight-bold'"
|
||||
>
|
||||
{{ $t('timesheet.date_ranges_to') }}
|
||||
</div>
|
||||
<div
|
||||
class="text-primary text-uppercase text-center text-weight-bold"
|
||||
:class="$q.screen.lt.md ? '' : 'text-h6'"
|
||||
>
|
||||
{{ pay_period_label.end_date }}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<q-card flat class=" col q-mt-md bg-secondary">
|
||||
<!-- navigation btn -->
|
||||
<q-card-section horizontal>
|
||||
<q-btn
|
||||
color="primary"
|
||||
unelevated
|
||||
icon="receipt_long"
|
||||
:label="$t('timesheet.expense.open_btn')"
|
||||
@click="openExpensesDialog"
|
||||
/>
|
||||
</q-card-section>
|
||||
<q-card-section class="row items-center justify-between q-px-md q-pb-none">
|
||||
<TimesheetNavigation
|
||||
:is-disabled="timesheet_store.is_loading"
|
||||
:is-previous-limit="timesheet_store.is_calendar_limit"
|
||||
@date-selected="onDateSelected"
|
||||
@pressed-previous-button="timesheet_api.getPreviousPeriodForUser(auth_store.user.email)"
|
||||
@pressed-next-button="timesheet_api.getNextPeriodForUser(auth_store.user.email)"
|
||||
/>
|
||||
</q-card-section>
|
||||
<!-- shift's colored legend -->
|
||||
<ShiftsLegend
|
||||
:is-loading="false"
|
||||
/>
|
||||
<q-card-section horizontal>
|
||||
<!-- display of shifts for 2 timesheets -->
|
||||
<DetailedShiftList
|
||||
:raw-data="timesheet_store.pay_period_employee_details"
|
||||
:current-pay-period="timesheet_store.current_pay_period"
|
||||
@request-add="onRequestAdd"
|
||||
@request-edit="onRequestEdit"
|
||||
@request-delete="onRequestDelete"
|
||||
/>
|
||||
<q-inner-loading :showing="timesheet_store.is_loading" color="primary"/>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
<!-- read/edit/create/delete expense dialog -->
|
||||
<q-dialog
|
||||
v-model="expenses_store.is_dialog_open"
|
||||
persistent
|
||||
>
|
||||
<q-card
|
||||
class="q-pa-md column"
|
||||
style=" min-width: 70vw;"
|
||||
>
|
||||
<q-inner-loading :showing="expenses_store.is_loading">
|
||||
<q-spinner size="32px"/>
|
||||
</q-inner-loading>
|
||||
|
||||
<!-- <q-banner
|
||||
v-if="expenses_error"
|
||||
dense
|
||||
class="bg-red-2 col-auto text-negative q-mt-sm"
|
||||
>
|
||||
{{ expenses_error }}
|
||||
</q-banner> -->
|
||||
|
||||
<TimesheetDetailsExpenses
|
||||
v-if="expenses_store.data"
|
||||
:pay_period_no="expenses_store.data.pay_period_no"
|
||||
:pay_year="expenses_store.data.pay_year"
|
||||
:email="expenses_store.data.employee_email"
|
||||
:is_approved="expenses_store.data.is_approved"
|
||||
:initial_expenses="expenses_store.data.expenses"
|
||||
@save="onSaveExpenses"
|
||||
@close="onCloseExpenses"
|
||||
@error=" "
|
||||
/>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<!-- shift crud dialog -->
|
||||
<ShiftCrudDialog
|
||||
v-model="shift_store.is_open"
|
||||
:mode="shift_store.mode"
|
||||
:date-iso="shift_store.date_iso"
|
||||
:email="auth_store.user.email"
|
||||
:initial-shift="shift_store.initial_shift"
|
||||
:shift-options="shift_options"
|
||||
@close="shift_store.close"
|
||||
@saved="onShiftSaved"
|
||||
<q-page
|
||||
padding
|
||||
class="q-pa-md bg-secondary"
|
||||
>
|
||||
<PageHeaderTemplate
|
||||
:title="$t('timesheet.title')"
|
||||
:start-date="pay_period_label.start_date"
|
||||
:end-date="pay_period_label.end_date"
|
||||
/>
|
||||
|
||||
</q-page>
|
||||
</template>
|
||||
|
|
@ -1,23 +1,14 @@
|
|||
import { api } from "src/boot/axios";
|
||||
import type { PayPeriod } from "src/modules/shared/types/pay-period-interface";
|
||||
import type { PayPeriodEmployeeDetails } from "src/modules/timesheet-approval/types/pay-period-employee-details";
|
||||
import type { PayPeriodOverview } from "src/modules/timesheet-approval/types/pay-period-overview";
|
||||
import type { PayPeriodReportFilters } from "src/modules/timesheet-approval/types/pay-period-report";
|
||||
import type { Timesheet } from "../types/timesheet.interfaces";
|
||||
import type { CreateShiftPayload, CreateWeekShiftPayload } from "../types/shift.interfaces";
|
||||
import type { UpsertShift } from "src/modules/timesheets/models/shift.models";
|
||||
import type { PayPeriod } from "src/modules/shared/models/pay-period.models";
|
||||
import type { PayPeriodDetails } from "src/modules/timesheets/models/pay-period-details.models";
|
||||
import type { PayPeriodOverview } from "src/modules/timesheet-approval/models/pay-period-overview.models";
|
||||
import type { PayPeriodExpenses } from "src/modules/timesheets/models/expense.models";
|
||||
|
||||
export const timesheetService = {
|
||||
//GET
|
||||
getTimesheetsByEmail: async ( email: string, offset = 0): Promise<Timesheet> => {
|
||||
const response = await api.get(`/timesheets/${encodeURIComponent(email)}`, {params: offset ? { offset } : undefined});
|
||||
return response.data as Timesheet;
|
||||
},
|
||||
|
||||
//POST
|
||||
createTimesheetShifts: async ( email: string, shifts: CreateShiftPayload[], offset = 0): Promise<Timesheet> => {
|
||||
const payload: CreateWeekShiftPayload = { shifts };
|
||||
const response = await api.post(`/timesheets/shifts/${encodeURIComponent(email)}`, payload, { params: offset ? { offset }: undefined });
|
||||
return response.data as Timesheet;
|
||||
getPayPeriodDetailsByEmployeeEmail: async (email: string): Promise<PayPeriodDetails> => {
|
||||
const response = await api.get(`/timesheets/${encodeURIComponent(email)}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getPayPeriodByDate: async (date_string: string): Promise<PayPeriod> => {
|
||||
|
|
@ -30,22 +21,18 @@ export const timesheetService = {
|
|||
return response.data;
|
||||
},
|
||||
|
||||
|
||||
getPayPeriodEmployeeOverviews: async (year: number, period_number: number, supervisor_email: string): Promise<PayPeriodOverview> => {
|
||||
// TODO: REMOVE MOCK DATA PEFORE PUSHING TO PROD
|
||||
getPayPeriodOverviewsBySupervisorEmail: async (year: number, period_number: number, supervisor_email: string): Promise<PayPeriodOverview[]> => {
|
||||
const response = await api.get(`pay-periods/${year}/${period_number}/${supervisor_email}`);
|
||||
console.log('pay period data: ', response.data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getTimesheetsByPayPeriodAndEmail: async (year: number, period_no: number, email: string): Promise<PayPeriodEmployeeDetails> => {
|
||||
const response = await api.get('timesheets', { params: { year, period_no, email, }});
|
||||
console.log('employee details: ', response.data);
|
||||
getPayPeriodDetailsByPayPeriodAndEmployeeEmail: async (year: number, period_no: number, email: string): Promise<PayPeriodDetails> => {
|
||||
const response = await api.get('timesheets', { params: { year, period_no, email, } });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getTimesheetApprovalCSVReport: async (year: number, period_number: number, report_filters?: PayPeriodReportFilters) => {
|
||||
const response = await api.get(`csv/${year}/${period_number}`, { params: { report_filters, }});
|
||||
return response.data;
|
||||
upsertOrDeletePayPeriodDetailsByDateAndEmployeeEmail: async (email: string, payload: UpsertShift[] | PayPeriodExpenses, pay_period: PayPeriod, date?: string): Promise<PayPeriodDetails> => {
|
||||
if (date) return (await api.put(`/shifts/upsert/${email}/${date}`, payload)).data;
|
||||
else return (await api.put(`/expenses/${email}/${pay_period.pay_year}/${pay_period.pay_period_no}`, payload, { headers: {'Content-Type': 'application/json'}})).data;
|
||||
},
|
||||
};
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
import type { TimesheetExpense } from "../types/expense.interfaces";
|
||||
import type { ExpenseSavePayload, ExpenseTotals, ExpenseType } from "../types/expense.types";
|
||||
/* eslint-disable */
|
||||
import type { Expense, ExpenseTotals, ExpenseType, PayPeriodExpenses } from "src/modules/timesheets/models/expense.models";
|
||||
|
||||
//------------------ normalization / icons ------------------
|
||||
export const normExpenseType = (type: unknown): string =>
|
||||
String(type ?? '').trim().toUpperCase();
|
||||
|
|
@ -21,16 +20,8 @@ export const expenseTypeIcon = (type: unknown): string => {
|
|||
);
|
||||
};
|
||||
|
||||
//------------------ q-select options ------------------
|
||||
export const buildExpenseTypeOptions = ( types: readonly ExpenseType[], t: (key:string) => string):
|
||||
{ label: string; value: ExpenseType } [] =>
|
||||
types.map((val)=> ({
|
||||
label: t(`timesheet.expense.types.${val}`),
|
||||
value: val,
|
||||
}));
|
||||
|
||||
//------------------ totals ------------------
|
||||
export const computeExpenseTotals = (items: readonly TimesheetExpense[]): ExpenseTotals =>
|
||||
export const computeExpenseTotals = (items: readonly Expense[]): ExpenseTotals =>
|
||||
items.reduce<ExpenseTotals>(
|
||||
(acc, e) => ({
|
||||
amount: acc.amount + (Number(e.amount) || 0),
|
||||
|
|
@ -47,7 +38,7 @@ export const makeExpenseRules = (t: (key: string) => string, max_comment_char: n
|
|||
|
||||
const amountRequired = (val: unknown) => (isPresent(val)) || t('timesheet.expense.errors.amount_required_for_type');
|
||||
|
||||
const mileageRequired = (val: unknown) => (isPresent(val)) || t('timesheet.expense.erros.mileage_required_for_type');
|
||||
const mileageRequired = (val: unknown) => (isPresent(val)) || t('timesheet.expense.errors.mileage_required_for_type');
|
||||
|
||||
const commentRequired = (val: unknown) => (String(val ?? '').trim().length > 0) || t('timesheet.expense.errors.comment_required');
|
||||
|
||||
|
|
@ -63,14 +54,11 @@ export const makeExpenseRules = (t: (key: string) => string, max_comment_char: n
|
|||
};
|
||||
|
||||
//------------------ saving payload ------------------
|
||||
export const buildExpenseSavePayload = (args: {
|
||||
pay_period_no: number;
|
||||
pay_year: number;
|
||||
email: string;
|
||||
expenses: TimesheetExpense[];
|
||||
}): ExpenseSavePayload => ({
|
||||
export const buildExpenseSavePayload = (args: PayPeriodExpenses): PayPeriodExpenses => ({
|
||||
pay_period_no: args.pay_period_no,
|
||||
pay_year: args.pay_year,
|
||||
email: args.email,
|
||||
employee_email: args.employee_email,
|
||||
is_approved: args.is_approved ?? false,
|
||||
expenses: args.expenses,
|
||||
totals: computeExpenseTotals(args.expenses),
|
||||
});
|
||||
|
|
@ -1,31 +1,29 @@
|
|||
import { COMMENT_MAX_LENGTH, DATE_FORMAT_PATTERN } from "../constants/expense.constants";
|
||||
import { ExpensesValidationError } from "../types/expense-validation.interface";
|
||||
import type { TimesheetExpense } from "../types/expense.interfaces";
|
||||
import {
|
||||
type ExpenseType,
|
||||
TYPES_WITH_AMOUNT_ONLY,
|
||||
TYPES_WITH_MILEAGE_ONLY
|
||||
} from "../types/expense.types";
|
||||
import { COMMENT_MAX_LENGTH, DATE_FORMAT_PATTERN } from "src/modules/timesheets/constants/expense.constants";
|
||||
import { ExpensesValidationError } from "src/modules/timesheets/models/expense.validation";
|
||||
import { type Expense, type ExpenseType, TYPES_WITH_AMOUNT_ONLY, TYPES_WITH_MILEAGE_ONLY } from "src/modules/timesheets/models/expense.models";
|
||||
|
||||
//normalization helpers
|
||||
export const toNumOrUndefined = (value: unknown): number | undefined => {
|
||||
if(value === undefined || value === null || (typeof value === 'string' && value.trim() === '')) return undefined;
|
||||
const num = Number(value);
|
||||
|
||||
return Number.isFinite(num) ? num : undefined;
|
||||
};
|
||||
|
||||
export const normalizeComment = (input?: string): string | undefined => {
|
||||
if(typeof input === 'undefined' || input === null) return undefined;
|
||||
const trimmed = String(input).trim();
|
||||
|
||||
return trimmed.length ? trimmed : undefined;
|
||||
};
|
||||
|
||||
export const normalizeType = (input: string): string => (input ?? '').trim().toUpperCase();
|
||||
|
||||
export const normalizeExpense = (expense: TimesheetExpense): TimesheetExpense => {
|
||||
export const normalizeExpense = (expense: Expense): Expense => {
|
||||
const comment = normalizeComment(expense.comment);
|
||||
const amount = toNumOrUndefined(expense.amount);
|
||||
const mileage = toNumOrUndefined(expense.mileage);
|
||||
|
||||
return {
|
||||
date: (expense.date ?? '').trim(),
|
||||
type: normalizeType(expense.type),
|
||||
|
|
@ -40,7 +38,7 @@ export const normalizeExpense = (expense: TimesheetExpense): TimesheetExpense =>
|
|||
};
|
||||
|
||||
//UI validation error messages
|
||||
export const validateExpenseUI = (raw: TimesheetExpense, label: string = 'expense'): void => {
|
||||
export const validateExpenseUI = (raw: Expense, label: string = 'expense'): void => {
|
||||
const expense = normalizeExpense(raw);
|
||||
|
||||
//Date input validation
|
||||
|
|
@ -60,6 +58,7 @@ export const validateExpenseUI = (raw: TimesheetExpense, label: string = 'expens
|
|||
context: { [label]: expense },
|
||||
})
|
||||
}
|
||||
|
||||
if((expense.comment.length ?? 0) > COMMENT_MAX_LENGTH) {
|
||||
throw new ExpensesValidationError({
|
||||
status_code: 400,
|
||||
|
|
@ -100,7 +99,7 @@ export const validateExpenseUI = (raw: TimesheetExpense, label: string = 'expens
|
|||
|
||||
//type constraint validation
|
||||
const type = expense.type as ExpenseType;
|
||||
if(TYPES_WITH_MILEAGE_ONLY.includes(type) && !has_mileage) {
|
||||
if( TYPES_WITH_MILEAGE_ONLY.includes(type) && !has_mileage ) {
|
||||
throw new ExpensesValidationError({
|
||||
status_code: 400,
|
||||
message: 'timesheet.expense.errors.mileage_required_for_type',
|
||||
|
|
@ -117,7 +116,7 @@ export const validateExpenseUI = (raw: TimesheetExpense, label: string = 'expens
|
|||
};
|
||||
|
||||
//totals per pay-period
|
||||
export const compute_expense_totals = (items: TimesheetExpense[]) => items.reduce(
|
||||
export const compute_expense_totals = (items: Expense[]) => items.reduce(
|
||||
(acc, raw) => {
|
||||
const expense = normalizeExpense(raw);
|
||||
if(typeof expense.amount === 'number' && expense.amount > 0) acc.amount += expense.amount;
|
||||
|
|
|
|||
|
|
@ -1,19 +1,18 @@
|
|||
import type { ShiftKey, ShiftPayload, ShiftSelectOption } from "../types/shift.types";
|
||||
/* eslint-disable */
|
||||
// import type { ShiftKey, ShiftPayload, ShiftSelectOption } from "../types/shift.types";
|
||||
|
||||
export const toShiftPayload = (shift: any): ShiftPayload => ({
|
||||
start_time: String(shift.start_time),
|
||||
end_time: String(shift.end_time),
|
||||
type: String(shift.type).toUpperCase() as ShiftKey,
|
||||
is_remote: !!shift.is_remote,
|
||||
...(shift.comment ? { comment: String(shift.comment) } : {}),
|
||||
});
|
||||
// export const toShiftPayload = (shift: any): ShiftPayload => ({
|
||||
// start_time: String(shift.start_time),
|
||||
// end_time: String(shift.end_time),
|
||||
// type: String(shift.type).toUpperCase() as ShiftKey,
|
||||
// is_remote: !!shift.is_remote,
|
||||
// ...(shift.comment ? { comment: String(shift.comment) } : {}),
|
||||
// });
|
||||
|
||||
export const buildShiftOptions = (
|
||||
keys: readonly string[],
|
||||
t:(k: string) => string
|
||||
): ShiftSelectOption[] =>
|
||||
keys.map((key) => ({
|
||||
value: key as any,
|
||||
label: t(`timesheet.shift.types.${key}`),
|
||||
}));
|
||||
// export const buildShiftOptions = (
|
||||
// keys: readonly string[],
|
||||
// t:(k: string) => string
|
||||
// ): ShiftSelectOption[] =>
|
||||
// keys.map((key) => ({
|
||||
// value: key as any,
|
||||
// label: t(`timesheet.shift.types.${key}`),
|
||||
// }));
|
||||
|
|
@ -1,23 +1,20 @@
|
|||
<script setup lang="ts">
|
||||
/* eslint-disable */
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-layout view="hHh lpR fFf">
|
||||
<q-page-container>
|
||||
<q-page padding class="column justify-center items-center bg-secondary">
|
||||
<q-card class="col-shrink rounded-20">
|
||||
<q-img src="src/assets/line-truck-1.jpg" height="20vh">
|
||||
<div class="absolute-bottom text-h4 text-center text-weight-bolder justify-center items-center row">
|
||||
<div class="q-pr-md text-primary text-h3 text-weight-bolder">404</div>
|
||||
PAGE NOT FOUND
|
||||
</div>
|
||||
</q-img>
|
||||
<q-card-section class="text-center text-h5 text-primary">
|
||||
{{$t('notFoundPage.pageText')}}
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-page>
|
||||
</q-page-container>
|
||||
</q-layout>
|
||||
<q-layout view="hHh lpR fFf">
|
||||
<q-page-container>
|
||||
<q-page padding class="column justify-center items-center bg-secondary">
|
||||
<q-card class="col-shrink rounded-20">
|
||||
<q-img src="src/assets/line-truck-1.jpg" height="20vh">
|
||||
<div
|
||||
class="absolute-bottom text-h4 text-center text-weight-bolder justify-center items-center row">
|
||||
<div class="q-pr-md text-primary text-h3 text-weight-bolder">404</div>
|
||||
PAGE NOT FOUND
|
||||
</div>
|
||||
</q-img>
|
||||
<q-card-section class="text-center text-h5 text-primary">
|
||||
{{ $t('notFoundPage.pageText') }}
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-page>
|
||||
</q-page-container>
|
||||
</q-layout>
|
||||
</template>
|
||||
|
|
@ -1,56 +1,73 @@
|
|||
<script setup lang="ts">
|
||||
import { useQuasar } from 'quasar';
|
||||
import type { QVueGlobals } from 'quasar';
|
||||
|
||||
const q: QVueGlobals = useQuasar();
|
||||
<script
|
||||
setup
|
||||
lang="ts"
|
||||
>
|
||||
import { useQuasar } from 'quasar';
|
||||
import type { QVueGlobals } from 'quasar';
|
||||
|
||||
const clickNotify = () => {
|
||||
q.notify({
|
||||
message: 'Nick pinged you.',
|
||||
})
|
||||
}
|
||||
const q: QVueGlobals = useQuasar();
|
||||
|
||||
const clickNotify = () => {
|
||||
q.notify({
|
||||
message: 'Nick pinged you.',
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-page padding class="q-pa-md row items-center justify-center">
|
||||
<q-card class="shadow-2 col-9 dark-font">
|
||||
<q-img src="src/assets/line-truck-1.jpg">
|
||||
<div class="absolute-bottom text-h5">
|
||||
Welcome to App Targo!
|
||||
</div>
|
||||
</q-img>
|
||||
<q-page
|
||||
padding
|
||||
class="q-pa-md row items-center justify-center"
|
||||
>
|
||||
<q-card class="shadow-2 col-9 dark-font">
|
||||
<q-img src="src/assets/line-truck-1.jpg">
|
||||
<div class="absolute-bottom text-h5">
|
||||
Welcome to App Targo!
|
||||
</div>
|
||||
</q-img>
|
||||
|
||||
<q-card-section class="text-center">
|
||||
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium,
|
||||
totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta
|
||||
sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia
|
||||
consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui
|
||||
dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora
|
||||
incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum
|
||||
exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem
|
||||
vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui
|
||||
dolorem eum fugiat quo voluptas nulla pariatur?
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section class="text-center">
|
||||
At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum deleniti atque corrupti quos dolores et quas molestias excepturi sint occaecati cupiditate non provident, similique sunt in culpa qui officia deserunt mollitia animi, id est laborum et dolorum fuga. Et harum quidem rerum facilis est et expedita distinctio. Nam libero tempore, cum soluta nobis est eligendi optio cumque nihil impedit quo minus id quod maxime placeat facere possimus, omnis voluptas assumenda est, omnis dolor repellendus. Temporibus autem quibusdam et aut officiis debitis aut rerum necessitatibus saepe eveniet ut et voluptates repudiandae sint et molestiae non recusandae. Itaque earum rerum hic tenetur a sapiente delectus, ut aut reiciendis voluptatibus maiores alias consequatur aut perferendis doloribus asperiores repellat.
|
||||
</q-card-section>
|
||||
<q-card-section class="text-center">
|
||||
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium,
|
||||
totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta
|
||||
sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia
|
||||
consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui
|
||||
dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora
|
||||
incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum
|
||||
exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem
|
||||
vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum
|
||||
qui
|
||||
dolorem eum fugiat quo voluptas nulla pariatur?
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section class="text-center">
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
|
||||
</q-card-section>
|
||||
<q-card-section class="text-center">
|
||||
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-separator />
|
||||
<q-card-section class="text-center">
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et
|
||||
dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip
|
||||
ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu
|
||||
fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia
|
||||
deserunt mollit anim id est laborum.
|
||||
</q-card-section>
|
||||
|
||||
<q-card-actions align="center">
|
||||
<q-btn color="primary" label="Click Me" @click="clickNotify" />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-page>
|
||||
</template>
|
||||
<q-separator />
|
||||
|
||||
<style scoped>
|
||||
.dark-font {
|
||||
color: #676;
|
||||
}
|
||||
</style>
|
||||
<q-card-actions align="center">
|
||||
<q-btn
|
||||
color="primary"
|
||||
label="Click Me"
|
||||
@click="clickNotify"
|
||||
/>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-page>
|
||||
</template>
|
||||
|
|
@ -1,88 +1,124 @@
|
|||
import { ref } from "vue";
|
||||
import { defineStore } from "pinia";
|
||||
import { type PayPeriodExpenses } from "src/modules/timesheets/types/expense.interfaces";
|
||||
import { ExpensesApiError } from "src/modules/timesheets/types/expense-validation.interface";
|
||||
import { getPayPeriodExpenses, putPayPeriodExpenses } from "src/modules/timesheets/composables/api/use-expense-api";
|
||||
import { useTimesheetStore } from "src/stores/timesheet-store";
|
||||
import
|
||||
import { default_expense, default_pay_period_expenses, type Expense, type PayPeriodExpenses } from "src/modules/timesheets/models/expense.models";
|
||||
import { unwrapAndClone } from "src/utils/unwrap-and-clone";
|
||||
|
||||
const { pay_period } = useTimesheetStore();
|
||||
|
||||
/* eslint-disable */
|
||||
export const useExpensesStore = defineStore('expenses', () => {
|
||||
const is_dialog_open = ref(false);
|
||||
const is_loading = ref(false);
|
||||
const data = ref<PayPeriodExpenses | null>(null);
|
||||
const error = ref<string | null>(null);
|
||||
const is_open = ref(false);
|
||||
const is_loading = ref(false);
|
||||
const current_expenses = ref<PayPeriodExpenses>(default_pay_period_expenses);
|
||||
const current_expense = ref<Expense>(default_expense);
|
||||
const initial_expense = ref<Expense>(default_expense);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
const setErrorFrom = (err: unknown, t?: (_key: string) => string) => {
|
||||
const setErrorFrom = (err: unknown) => {
|
||||
const e = err as any;
|
||||
error.value = (err instanceof ExpensesApiError && t
|
||||
? t(e.message): undefined)
|
||||
|| e?.message
|
||||
|| 'Unknown error';
|
||||
error.value = e?.message || 'Unknown error';
|
||||
};
|
||||
|
||||
const openDialog = async (
|
||||
params: { email: string; pay_year: number; pay_period_no: number; t?: (_key: string)=> string}) => {
|
||||
is_dialog_open.value = true;
|
||||
is_loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const response = await getPayPeriodExpenses(
|
||||
params.email,
|
||||
params.pay_year,
|
||||
params.pay_period_no,
|
||||
);
|
||||
data.value = response;
|
||||
} catch (err) {
|
||||
setErrorFrom(err, params.t);
|
||||
data.value = {
|
||||
pay_period_no: params.pay_period_no,
|
||||
pay_year: params.pay_year,
|
||||
employee_email: params.email,
|
||||
is_approved: false,
|
||||
expenses: [],
|
||||
totals: { amount: 0, mileage: 0},
|
||||
};
|
||||
} finally {
|
||||
is_loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const saveExpenses = async (payload: {
|
||||
email: string;
|
||||
pay_year: number;
|
||||
pay_period_no: number;
|
||||
expenses: any[]; t?: (_key: string) => string
|
||||
}) => {
|
||||
const open = async (employee_email: string) => {
|
||||
is_open.value = true;
|
||||
is_loading.value = true;
|
||||
error.value = null;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const updated = await putPayPeriodExpenses(
|
||||
payload.email,
|
||||
payload.pay_year,
|
||||
payload.pay_period_no,
|
||||
payload.expenses
|
||||
);
|
||||
data.value = updated;
|
||||
is_dialog_open.value = false;
|
||||
const response = await getPayPeriodExpenses(employee_email, pay_period.pay_year, pay_period.pay_period_no,);
|
||||
current_expenses.value = response;
|
||||
initial_expenses.value = unwrapAndClone(response);
|
||||
} catch (err) {
|
||||
setErrorFrom(err, payload.t);
|
||||
setErrorFrom(err);
|
||||
current_expenses.value = default_pay_period_expenses;
|
||||
initial_expenses.value = default_pay_period_expenses;
|
||||
} finally {
|
||||
is_loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const getPayPeriodExpensesByEmployeeEmail = async (employee_email: string): Promise<PayPeriodExpenses> => {
|
||||
const encoded_email = encodeURIComponent(employee_email);
|
||||
const encoded_year = encodeURIComponent(String(pay_period.pay_year));
|
||||
const encoded_pay_period_no = encodeURIComponent(String(pay_period.pay_period_no));
|
||||
|
||||
try {
|
||||
const { data } = await api.get<PayPeriodExpenses>(`/expenses/${encoded_email}/${encoded_year}/${encoded_pay_period_no}`);
|
||||
|
||||
const items = Array.isArray(data.expenses) ? data.expenses.map(normalizeExpense) : [];
|
||||
return {
|
||||
...data,
|
||||
expenses: items,
|
||||
};
|
||||
} catch(err:any) {
|
||||
const status_code: number = err?.response?.status ?? 500;
|
||||
const data = err?.response?.data ?? {};
|
||||
throw new ExpensesApiError({
|
||||
status_code,
|
||||
error_code: data.error_code,
|
||||
message: data.message || data.error || err.message,
|
||||
context: data.context,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onSave = () => {
|
||||
try {
|
||||
validateAll();
|
||||
reset();
|
||||
emit('save', buildExpenseSavePayload({
|
||||
pay_period_no: pay_period.pay_period_no,
|
||||
pay_year: pay_period.pay_year,
|
||||
employee_email: employeeEmail,
|
||||
is_approved: false,
|
||||
expenses: payload(),
|
||||
}));
|
||||
|
||||
} catch (err: any) {
|
||||
emit('error', toExpensesError(err));
|
||||
}
|
||||
};
|
||||
|
||||
const onFormSubmit = async () => {
|
||||
try {
|
||||
await validateAnd(async () => {
|
||||
addFromDraft();
|
||||
reset();
|
||||
});
|
||||
} catch (err: any) {
|
||||
emit('error', toExpensesError(err));
|
||||
}
|
||||
};
|
||||
|
||||
const upsertOrDeletePayPeriodExpenseByEmployeeEmail = async (employee_email: string, expenses: Expense[]) => {
|
||||
is_loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const updated = await putPayPeriodExpenses(employee_email, pay_period.pay_year, pay_period.pay_period_no, expenses);
|
||||
pay_period_expenses.value = updated;
|
||||
is_open.value = false;
|
||||
} catch (err) {
|
||||
setErrorFrom(err);
|
||||
} finally {
|
||||
is_loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
error.value = null;
|
||||
is_dialog_open.value = false;
|
||||
const close = () => {
|
||||
error.value = null;
|
||||
is_open.value = false;
|
||||
};
|
||||
|
||||
return {
|
||||
is_dialog_open,
|
||||
is_open,
|
||||
is_loading,
|
||||
data,
|
||||
current_expenses,
|
||||
initial_expenses,
|
||||
error,
|
||||
openDialog,
|
||||
saveExpenses,
|
||||
closeDialog,
|
||||
open,
|
||||
upsertOrDeletePayPeriodExpenseByEmployeeEmail,
|
||||
close,
|
||||
};
|
||||
});
|
||||
|
|
@ -1,50 +1,80 @@
|
|||
import { defineStore } from "pinia";
|
||||
import { ref } from "vue";
|
||||
import { toShiftPayload } from "src/modules/timesheets/utils/shift.util";
|
||||
import type { FormMode } from "src/modules/timesheets/types/ui.types";
|
||||
import type { ShiftPayload } from "src/modules/timesheets/types/shift.types";
|
||||
import { defineStore } from "pinia";
|
||||
import { unwrapAndClone } from "src/utils/unwrap-and-clone";
|
||||
import { timesheetService } from "src/modules/timesheets/services/timesheet-service";
|
||||
import { useTimesheetStore } from "src/stores/timesheet-store";
|
||||
import { default_shift, type UpsertAction, type Shift, UpsertShift } from "src/modules/timesheets/models/shift.models";
|
||||
import { GenericApiError } from "src/modules/timesheets/models/expense.validation";
|
||||
|
||||
/* eslint-disable */
|
||||
export const useShiftStore = defineStore('shift', () => {
|
||||
const is_open = ref(false);
|
||||
const mode = ref<FormMode>('create');
|
||||
const date_iso = ref<string>('');
|
||||
const initial_shift = ref<ShiftPayload | null>(null);
|
||||
const is_open = ref(false);
|
||||
const mode = ref<UpsertAction>('create');
|
||||
const date_iso = ref<string>('');
|
||||
const current_shift = ref<Shift>(default_shift);
|
||||
const initial_shift = ref<Shift>(default_shift);
|
||||
|
||||
const open = (nextMode: FormMode, date: string, payload: ShiftPayload | null) => {
|
||||
mode.value = nextMode;
|
||||
date_iso.value = date;
|
||||
initial_shift.value = payload;
|
||||
is_open.value = true;
|
||||
const timesheet_store = useTimesheetStore();
|
||||
|
||||
const open = (next_mode: UpsertAction, date: string, current: Shift, initial: Shift) => {
|
||||
mode.value = next_mode;
|
||||
date_iso.value = date;
|
||||
current_shift.value = current; // new shift
|
||||
initial_shift.value = initial; // old shift
|
||||
is_open.value = true;
|
||||
};
|
||||
|
||||
const openCreate = (date: string) => {
|
||||
open('create', date, null);
|
||||
open('create', date, default_shift, default_shift);
|
||||
};
|
||||
|
||||
const openEdit = (date: string, shift: any) => {
|
||||
open('edit', date, toShiftPayload(shift as any));
|
||||
const openUpdate = (date: string, shift: Shift) => {
|
||||
open('update', date, shift, unwrapAndClone(shift));
|
||||
};
|
||||
|
||||
const openDelete = (date: string, shift: any) => {
|
||||
open('delete', date, toShiftPayload(shift as any));
|
||||
open('delete', date, default_shift, shift);
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
is_open.value = false;
|
||||
mode.value = 'create';
|
||||
date_iso.value = '';
|
||||
initial_shift.value = null;
|
||||
const close = () => {
|
||||
is_open.value = false;
|
||||
mode.value = 'create';
|
||||
date_iso.value = '';
|
||||
current_shift.value = default_shift;
|
||||
initial_shift.value = default_shift;
|
||||
};
|
||||
|
||||
const upsertOrDeleteShiftByEmployeeEmail = async (employee_email: string, upsert_shift: UpsertShift) => {
|
||||
const encoded_email = encodeURIComponent(employee_email);
|
||||
const encoded_date = encodeURIComponent(current_shift.value.date);
|
||||
|
||||
try {
|
||||
const result = await timesheetService.upsertOrDeletePayPeriodShifts(encoded_email, encoded_date, [ upsert_shift, ]);
|
||||
timesheet_store.pay_period_details = result;
|
||||
} catch (err: any) {
|
||||
const status_code: number = err?.response?.status ?? 500;
|
||||
const data = err?.response?.data ?? {};
|
||||
throw new GenericApiError({
|
||||
status_code,
|
||||
error_code: data.error_code,
|
||||
message: data.message || data.error || err.message,
|
||||
context: data.context,
|
||||
});
|
||||
} finally {
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
is_open,
|
||||
mode,
|
||||
date_iso,
|
||||
current_shift,
|
||||
initial_shift,
|
||||
openCreate,
|
||||
openEdit,
|
||||
openUpdate,
|
||||
openDelete,
|
||||
close,
|
||||
upsertOrDeleteShiftByEmployeeEmail,
|
||||
};
|
||||
})
|
||||
|
|
@ -3,21 +3,18 @@ import { computed, ref } from 'vue';
|
|||
import { withLoading } from 'src/utils/store-helpers';
|
||||
import { timesheetApprovalService } from 'src/modules/timesheet-approval/services/timesheet-approval-service';
|
||||
import { timesheetService } from 'src/modules/timesheets/services/timesheet-service';
|
||||
import { default_timesheet_approval_overview_crew, type TimesheetApprovalOverviewCrew } from "src/modules/timesheet-approval/models/timesheet-approval-overview.models";
|
||||
// import type { Timesheet } from 'src/modules/timesheets/types/timesheet.interfaces';
|
||||
import type { TimesheetDetails } from 'src/modules/timesheets/models/timesheet.models';
|
||||
import { default_timesheet_details } from 'src/modules/timesheets/types/timesheet.defaults';
|
||||
import { default_pay_period_overview, type PayPeriodOverview } from "src/modules/timesheet-approval/models/pay-period-overview.models";
|
||||
import { default_pay_period, type PayPeriod } from 'src/modules/shared/types/pay-period-interface';
|
||||
import type { PayPeriodReportFilters } from 'src/modules/timesheet-approval/types/pay-period-report';
|
||||
import { default_pay_period_details, type PayPeriodDetails } from 'src/modules/timesheets/models/pay-period-details.models';
|
||||
import { TimesheetApprovalCSVReportFilters } from 'src/modules/timesheet-approval/models/timesheet-approval-csv-report.models';
|
||||
|
||||
export const useTimesheetStore = defineStore('timesheet', () => {
|
||||
const is_loading = ref<boolean>(false);
|
||||
const pay_period = ref<PayPeriod>(default_pay_period);
|
||||
const timesheet_approval_overview_list = ref<TimesheetApprovalOverview[]>([]);
|
||||
const timesheet_aproval_overview = ref<TimesheetApprovalOverview>(default_pay_period_employee_overview);
|
||||
const pay_period_employee_details = ref<TimesheetDetails>(default_timesheet_details);
|
||||
const pay_period_overviews = ref<PayPeriodOverview[]>([ default_pay_period_overview, ]);
|
||||
const current_pay_period_overview = ref<PayPeriodOverview>(default_pay_period_overview);
|
||||
const pay_period_details = ref<PayPeriodDetails>(default_pay_period_details);
|
||||
const pay_period_report = ref();
|
||||
// const timesheet = ref<Timesheet>(default_timesheet);
|
||||
const is_calendar_limit = computed( ()=>
|
||||
pay_period.value.pay_year === 2024 &&
|
||||
pay_period.value.pay_period_no <= 1
|
||||
|
|
@ -29,11 +26,11 @@ export const useTimesheetStore = defineStore('timesheet', () => {
|
|||
let response;
|
||||
|
||||
if (typeof date_or_year === 'string') {
|
||||
response = await timesheetApprovalService.getPayPeriodByDate(date_or_year);
|
||||
response = await timesheetService.getPayPeriodByDate(date_or_year);
|
||||
return true;
|
||||
}
|
||||
else if ( typeof date_or_year === 'number' && period_number ) {
|
||||
response = await timesheetApprovalService.getPayPeriodByYearAndPeriodNumber(date_or_year, period_number);
|
||||
response = await timesheetService.getPayPeriodByYearAndPeriodNumber(date_or_year, period_number);
|
||||
return true;
|
||||
}
|
||||
else response = default_pay_period;
|
||||
|
|
@ -43,7 +40,7 @@ export const useTimesheetStore = defineStore('timesheet', () => {
|
|||
} catch(error){
|
||||
console.error('Could not get current pay period: ', error );
|
||||
pay_period.value = default_pay_period;
|
||||
pay_period_employee_overview_list.value = [];
|
||||
pay_period_overviews.value = [ default_pay_period_overview, ];
|
||||
//TODO: More in-depth error-handling here
|
||||
}
|
||||
|
||||
|
|
@ -51,58 +48,15 @@ export const useTimesheetStore = defineStore('timesheet', () => {
|
|||
});
|
||||
};
|
||||
|
||||
const getPayPeriodEmployeeOverviewListBySupervisorEmail = async (pay_year: number, period_number: number, supervisor_email: string): Promise<boolean> => {
|
||||
const getPayPeriodDetailsByEmployeeEmail = async (employee_email: string) => {
|
||||
return withLoading( is_loading, async () => {
|
||||
try {
|
||||
const response = await timesheetApprovalService.getPayPeriodEmployeeOverviewListBySupervisorEmail( pay_year, period_number, supervisor_email );
|
||||
pay_period_employee_overview_list.value = response.employees_overview;
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('There was an error retrieving Employee Pay Period overviews: ', error);
|
||||
pay_period_employee_overview_list.value = [];
|
||||
// TODO: More in-depth error-handling here
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
};
|
||||
|
||||
const getPayPeriodOverviewByEmployeeEmail = (email: string): PayPeriodEmployeeOverview => {
|
||||
const response = pay_period_employee_overview_list.value?.find( employee_overview => employee_overview.email === email);
|
||||
if (typeof response === 'undefined') {
|
||||
pay_period_employee_overview.value = default_pay_period_employee_overview;
|
||||
} else {
|
||||
pay_period_employee_overview.value = response;
|
||||
}
|
||||
|
||||
return pay_period_employee_overview.value;
|
||||
};
|
||||
|
||||
// const getTimesheetByEmail = async (employee_email: string) => {
|
||||
// return withLoading( is_loading, async () => {
|
||||
// try{
|
||||
// const response = await timesheetTempService.getTimesheetsByEmail(employee_email);
|
||||
// timesheet.value = response;
|
||||
|
||||
// return true;
|
||||
// }catch (error) {
|
||||
// console.error('There was an error retrieving timesheet details for this employee: ', error);
|
||||
// timesheet.value = { ...default_timesheet }
|
||||
// }
|
||||
|
||||
// return false;
|
||||
// });
|
||||
// };
|
||||
|
||||
const getPayPeriodEmployeeDetailsByEmployeeEmail = async (employee_email: string) => {
|
||||
return withLoading( is_loading, async () => {
|
||||
try {
|
||||
const response = await timesheetApprovalService.getPayPeriodEmployeeDetailsByPayPeriodAndEmail(
|
||||
const response = await timesheetService.getPayPeriodDetailsByPayPeriodAndEmployeeEmail(
|
||||
pay_period.value.pay_year,
|
||||
pay_period.value.pay_period_no,
|
||||
employee_email
|
||||
);
|
||||
pay_period_employee_details.value = response;
|
||||
pay_period_details.value = response;
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
|
|
@ -110,17 +64,33 @@ export const useTimesheetStore = defineStore('timesheet', () => {
|
|||
// TODO: More in-depth error-handling here
|
||||
}
|
||||
|
||||
pay_period_employee_details.value = default_pay_period_employee_details;
|
||||
pay_period_details.value = default_pay_period_details;
|
||||
return false;
|
||||
});
|
||||
};
|
||||
|
||||
const getTimesheetApprovalCSVReport = async (report_filters?: PayPeriodReportFilters) => {
|
||||
const getPayPeriodOverviewsBySupervisorEmail = async (pay_year: number, period_number: number, supervisor_email: string): Promise<boolean> => {
|
||||
return withLoading( is_loading, async () => {
|
||||
try {
|
||||
const response = await timesheetApprovalService.getTimesheetApprovalCSVReport(
|
||||
pay_period.value.pay_year,
|
||||
pay_period.value.pay_period_no,
|
||||
const response = await timesheetApprovalService.getPayPeriodOverviewsBySupervisorEmail( pay_year, period_number, supervisor_email );
|
||||
pay_period_overviews.value = response;
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('There was an error retrieving Employee Pay Period overviews: ', error);
|
||||
pay_period_overviews.value = [ default_pay_period_overview, ];
|
||||
// TODO: More in-depth error-handling here
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
};
|
||||
|
||||
const getPayPeriodReportByYearAndPeriodNumber = async (year: number, period_number: number, report_filters?: TimesheetApprovalCSVReportFilters) => {
|
||||
return withLoading( is_loading, async () => {
|
||||
try {
|
||||
const response = await timesheetApprovalService.getPayPeriodReportByYearAndPeriodNumber(
|
||||
year,
|
||||
period_number,
|
||||
report_filters
|
||||
);
|
||||
pay_period_report.value = response;
|
||||
|
|
@ -136,18 +106,15 @@ export const useTimesheetStore = defineStore('timesheet', () => {
|
|||
};
|
||||
|
||||
return {
|
||||
pay_period,
|
||||
pay_period_employee_overview_list,
|
||||
pay_period_employee_overview,
|
||||
pay_period_employee_details,
|
||||
timesheet,
|
||||
is_loading,
|
||||
is_calendar_limit,
|
||||
pay_period,
|
||||
pay_period_overviews,
|
||||
current_pay_period_overview,
|
||||
pay_period_details,
|
||||
getPayPeriodByDateOrYearAndNumber,
|
||||
// getTimesheetByEmail,
|
||||
getPayPeriodEmployeeOverviewListBySupervisorEmail,
|
||||
getPayPeriodOverviewByEmployeeEmail,
|
||||
getPayPeriodEmployeeDetailsByEmployeeEmail,
|
||||
getTimesheetApprovalCSVReport,
|
||||
getPayPeriodOverviewsBySupervisorEmail,
|
||||
getPayPeriodDetailsByEmployeeEmail,
|
||||
getPayPeriodReportByYearAndPeriodNumber,
|
||||
};
|
||||
});
|
||||
|
|
@ -1,28 +1,41 @@
|
|||
export const deepEqual = (a: unknown, b: unknown): boolean => {
|
||||
if (a === b) {
|
||||
return true;
|
||||
}
|
||||
import { unwrapAndClone } from "src/utils/unwrap-and-clone";
|
||||
|
||||
if (
|
||||
a == null || // handles null and undefined
|
||||
b == null ||
|
||||
typeof a !== 'object' ||
|
||||
typeof b !== 'object'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
/**
|
||||
* Internal recursive function comparing two plain values.
|
||||
*/
|
||||
const _deepEqualRecursive = (a: unknown, b: unknown): boolean => {
|
||||
if (a === b) return true;
|
||||
|
||||
const aKeys = Object.keys(a as Record<string, unknown>);
|
||||
const bKeys = Object.keys(b as Record<string, unknown>);
|
||||
if (a == null || b == null || typeof a !== "object" || typeof b !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (aKeys.length !== bKeys.length) {
|
||||
return false;
|
||||
}
|
||||
// Handle arrays
|
||||
if (Array.isArray(a) && Array.isArray(b)) {
|
||||
if (a.length !== b.length) return false;
|
||||
return a.every((val, i) => _deepEqualRecursive(val, b[i]));
|
||||
} else if (Array.isArray(a) || Array.isArray(b)) {
|
||||
return false; // one is array, other is not
|
||||
}
|
||||
|
||||
return aKeys.every((key) =>
|
||||
deepEqual(
|
||||
(a as Record<string, unknown>)[key],
|
||||
(b as Record<string, unknown>)[key]
|
||||
)
|
||||
);
|
||||
const aKeys = Object.keys(a as Record<string, unknown>);
|
||||
const bKeys = Object.keys(b as Record<string, unknown>);
|
||||
|
||||
if (aKeys.length !== bKeys.length) return false;
|
||||
|
||||
return aKeys.every((key) =>
|
||||
_deepEqualRecursive(
|
||||
(a as Record<string, unknown>)[key],
|
||||
(b as Record<string, unknown>)[key]
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Deep equality check that normalizes reactive objects first.
|
||||
*/
|
||||
export const deepEqual = (given: unknown, expected: unknown): boolean => {
|
||||
const a = unwrapAndClone(given as object);
|
||||
const b = unwrapAndClone(expected as object);
|
||||
return _deepEqualRecursive(a, b);
|
||||
};
|
||||
|
|
|
|||
31
src/utils/normalize-object.ts
Normal file
31
src/utils/normalize-object.ts
Normal 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);
|
||||
|
|
@ -1,10 +1,8 @@
|
|||
import type { Ref } from "vue";
|
||||
|
||||
export const withLoading = async <T>( loading_state: Ref<boolean>, fn: () => Promise<T> ) => {
|
||||
loading_state.value = true;
|
||||
export const withLoading = async <T>( loading_state: boolean, fn: () => Promise<T> ) => {
|
||||
loading_state = true;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
loading_state.value = false;
|
||||
loading_state = false;
|
||||
}
|
||||
};
|
||||
6
src/utils/to-qselect-options.ts
Normal file
6
src/utils/to-qselect-options.ts
Normal 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
|
||||
}));
|
||||
};
|
||||
16
src/utils/unwrap-and-clone.ts
Normal file
16
src/utils/unwrap-and-clone.ts
Normal 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));
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user