Merge branch 'main' of git.targo.ca:Targo/targo_frontend into dev/nicolas/approvals-DRYing

This commit is contained in:
Nicolas Drolet 2025-10-03 12:24:42 -04:00
commit ebc3bde10c
9 changed files with 493 additions and 311 deletions

View File

@ -2,15 +2,16 @@
setup setup
lang="ts" lang="ts"
> >
import type { ExpenseType, Expense } from 'src/modules/timesheets/models/expense.models';
import { ref } from 'vue'; import { ref } from 'vue';
import { useExpensesStore } from 'src/stores/expense-store';
import type { ExpenseType, Expense } from 'src/modules/timesheets/models/expense.models';
const expense_store = useExpensesStore();
const files = defineModel<File[] | null>('files'); const files = defineModel<File[] | null>('files');
const is_navigator_open = ref(false); const is_navigator_open = ref(false);
//------------------ props ------------------ //------------------ props ------------------
defineProps<{ defineProps<{
type_options: { label: string; value: ExpenseType }[]; type_options: { label: string; value: ExpenseType }[];
show_amount: boolean; show_amount: boolean;
is_readonly: boolean; is_readonly: boolean;
@ -35,7 +36,7 @@
<q-form <q-form
flat flat
v-if="!is_readonly" v-if="!is_readonly"
@submit.prevent="emit('submit')" @submit.prevent="$emit('submit')"
> >
<div class="text-subtitle2 q-py-sm"> <div class="text-subtitle2 q-py-sm">
{{ $t('timesheet.expense.add_expense') }} {{ $t('timesheet.expense.add_expense') }}
@ -44,7 +45,7 @@
<!-- date selection input --> <!-- date selection input -->
<q-input <q-input
v-model.date="draft!.date" v-model="expense_store.current_expense.date"
dense dense
filled filled
readonly readonly
@ -59,12 +60,12 @@
dense dense
icon="event" icon="event"
color="primary" color="primary"
@click="datePickerOpen = true" @click="is_navigator_open = true"
/> />
<q-dialog v-model="datePickerOpen"> <q-dialog v-model="is_navigator_open">
<q-date <q-date
v-model="draft!.date" v-model="expense_store.current_expense.date"
@update:model-value="datePickerOpen = false" @update:model-value="is_navigator_open = false"
mask="YYYY-MM-DD" mask="YYYY-MM-DD"
/> />
</q-dialog> </q-dialog>
@ -73,7 +74,7 @@
<!-- expenses type selection --> <!-- expenses type selection -->
<q-select <q-select
v-model="draft!.type" v-model="expense_store.current_expense.type"
:options="type_options" :options="type_options"
filled filled
dense dense
@ -91,7 +92,7 @@
<template v-if="show_amount"> <template v-if="show_amount">
<q-input <q-input
key="amount" key="amount"
v-model.number="draft!.amount" v-model.number="expense_store.current_expense.amount"
filled filled
input-class="text-right" input-class="text-right"
dense dense
@ -110,7 +111,7 @@
<template v-else> <template v-else>
<q-input <q-input
key="mileage" key="mileage"
v-model.number="draft!.mileage" v-model.number="expense_store.current_expense.mileage"
filled filled
input-class="text-right" input-class="text-right"
dense dense
@ -127,7 +128,7 @@
<!-- employee comment input --> <!-- employee comment input -->
<q-input <q-input
v-model="draft!.comment" v-model="expense_store.current_expense.comment"
filled filled
color="primary" color="primary"
type="text" type="text"
@ -142,7 +143,7 @@
> >
<template #label> <template #label>
<span class="text-weight-bold "> <span class="text-weight-bold ">
{{ $t('timesheet.expense.employee_comment') }} {{ $t('timesheet.expense.comment') }}
</span> </span>
</template> </template>
</q-input> </q-input>

View File

@ -2,13 +2,14 @@
import type { TimesheetExpense } from '../../types/expense.interfaces'; import type { TimesheetExpense } from '../../types/expense.interfaces';
import { expenseTypeIcon } from '../../utils/expense.util'; import { expenseTypeIcon } from '../../utils/expense.util';
/* eslint-disable */ /* eslint-disable */
const props = defineProps<{ defineProps<{
items: TimesheetExpense[]; items: TimesheetExpense[];
is_readonly: boolean; is_readonly: boolean;
}>(); }>();
const emit = defineEmits<{ defineEmits<{
(e: 'remove', index: number): void; (e: 'remove', index: number): void;
(e: 'edit' , index: number): void;
}>(); }>();
</script> </script>
@ -36,7 +37,12 @@ const emit = defineEmits<{
<!-- amount or mileage section --> <!-- amount or mileage section -->
<q-item-section top> <q-item-section top>
<q-item-label v-if="String(expense.type).trim().toUpperCase() === 'MILEAGE'"> <q-item-label v-if="String(expense.type).trim().toUpperCase() === 'MILEAGE'">
{{ expense.mileage?.toFixed(1) }} km <template v-if="typeof expense.mileage === 'number'">
{{ expense.mileage?.toFixed(1) }} km
</template>
<template v-else>
{{ expense.amount?.toFixed(2) }} $
</template>
</q-item-label> </q-item-label>
<q-item-label v-else> <q-item-label v-else>
{{ expense.amount?.toFixed(2) }} $ {{ expense.amount?.toFixed(2) }} $
@ -78,7 +84,20 @@ const emit = defineEmits<{
<q-item-label v-if="expense.supervisor_comment" caption lines="2"> <q-item-label v-if="expense.supervisor_comment" caption lines="2">
{{ expense.supervisor_comment }} {{ expense.supervisor_comment }}
</q-item-label> </q-item-label>
</q-item-section> </q-item-section>
<!-- delete btn -->
<q-item-section side>
<q-btn
v-if="!is_readonly"
push
dense
size="xs"
color="primary"
icon="edit"
@click="$emit('edit', index)"
/>
</q-item-section>
<!-- delete btn --> <!-- delete btn -->
<q-item-section side> <q-item-section side>
@ -89,7 +108,7 @@ const emit = defineEmits<{
size="xs" size="xs"
color="negative" color="negative"
icon="close" icon="close"
@click="emit('remove', index)" @click="$emit('remove', index)"
/> />
</q-item-section> </q-item-section>
</q-item> </q-item>

View File

@ -0,0 +1,267 @@
<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 { 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 { COMMENT_MAX_LENGTH } from '../../constants/expense.constants';
import { ExpensesValidationError } from '../../types/expense-validation.interface';
import { EXPENSE_TYPE } from '../../types/expense.types';
import type { ExpenseType } from '../../types/expense.types';
import type { ExpenseDay, TimesheetExpense } from '../../types/expense.interfaces';
import {
createExpenseByDate,
deleteExpenseByDate,
getPayPeriodExpenses,
updateExpenseByDate
} from '../../composables/api/use-expense-api';
/* eslint-disable */
const { t, locale } = useI18n();
const rules = makeExpenseRules(t, COMMENT_MAX_LENGTH);
//------------------ props ------------------
const {email, pay_period_no, pay_year, is_approved, initial_expenses} = 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(() => !!is_approved);
const editing_old = ref<ExpenseDay | null>(null);
const { state: is_open_date_picker } = useToggle();
const { draft, setType, reset, showAmount } = useExpenseDraft();
const { formRef, validateAnd } = useExpenseForm();
const { items, validateAll, payload } = useExpenseItems({
initial_expenses: initial_expenses,
is_approved: is_readonly,
draft,
});
const totals = computed(() => computeExpenseTotals(items.value));
//------------------ actions ------------------
const onSave = () => {
try {
validateAll();
reset();
emit('save', buildExpenseSavePayload({
pay_period_no: pay_period_no,
pay_year: pay_year,
email: 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 refreshFromServer = async () => {
const fresh = await getPayPeriodExpenses(email, pay_year, pay_period_no);
items.value = Array.isArray(fresh.expenses) ? fresh.expenses : [];
};
const onFormSubmit = async () => {
try {
await validateAnd(async () => {
if (is_readonly.value) throw new Error(t('common.read_only') || 'Read-only');
const day = draft.value;
if (!day?.date || !day?.type || !day?.comment) {
throw new ExpensesValidationError({ status_code: 400, message: 'Missing required fields' });
}
const is_mileage = String(day.type).toUpperCase() === 'MILEAGE';
const new_payload = {
date: day.date,
type: day.type as ExpenseType,
comment: day.comment,
...(is_mileage && typeof day.mileage === 'number'
? { mileage: day.mileage }
: !is_mileage && typeof day.amount === 'number'
? { amount: day.amount }
: {}),
};
if(editing_old.value) {
await updateExpenseByDate(email, editing_old.value, new_payload as any);
editing_old.value = null;
} else {
await createExpenseByDate(email, new_payload as any);
}
await refreshFromServer();
reset();
});
} catch (err: any) {
const e = err instanceof ExpensesValidationError ? err : new ExpensesValidationError({
status_code: 400,
message: String(err?.message || err)
});
emit('error', e);
}
};
const onRemove = async (index: number) => {
try {
if (is_readonly.value) throw new Error(t('common.read_only') || 'Read-only');
const item = items.value[index];
if (!item) return;
const is_mileage = String(item.type).toUpperCase() === 'MILEAGE';
const old_payload: any = {
date: item.date,
type: item.type as ExpenseType,
comment: item.comment ?? '',
...(is_mileage && typeof item.mileage === 'number'
? { mileage: item.mileage }
: !is_mileage && typeof item.amount === 'number'
? { amount: item.amount }
: {}),
};
await deleteExpenseByDate(email, old_payload as any);
await refreshFromServer();
} catch (err: any) {
const e = err instanceof ExpensesValidationError ? err : new ExpensesValidationError({
status_code: 400, message: String(err?.message || err)
});
emit('error', e);
}
};
const onEdit = async (index: number) => {
if(is_readonly) return;
const item = items.value[index];
if(!item) return;
const old_amount = Number(item.amount) || 0;
editing_old.value = {
date: item.date,
type: item.type as ExpenseType,
amount: old_amount,
comment: item.comment ?? '',
is_approved: !!item.is_approved,
};
const is_mileage = String(item.type).toUpperCase() === 'MILEAGE';
const next: Partial<TimesheetExpense> = {
date: item.date,
type: item.type,
comment: item.comment ?? '',
...(is_mileage
? (typeof item.mileage === 'number' ? { mileage: item.mileage } : {})
: (typeof item.amount === 'number' ? { amnount: item.amount } : {})),
};
(draft as any).value = next;
setType(item.type as ExpenseType);
};
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="onRemove"
@edit="onEdit"
/>
<ExpenseForm
ref="formRef"
v-model:draft="draft"
v-model:files="files"
v-model:date-picker-open="is_open_date_picker"
:type_options="type_options"
:show_amount="showAmount"
:is_readonly="is_readonly"
:rules="rules"
:comment_max_length="COMMENT_MAX_LENGTH"
:set-type="setType"
@submit="onFormSubmit"
/>
<q-separator spaced />
<div class="row col-auto justify-end">
<!-- close btn -->
<q-btn
flat
class="q-mr-sm"
color="primary"
:label="$t('timesheet.cancel_button')"
@click="onClose"
/>
<!-- save btn -->
<q-btn
color="primary"
unelevated
push
:disable="is_readonly || items.length === 0"
:label="$t('timesheet.save_button')"
@click="onSave"
/>
</div>
</div>
</template>

View File

@ -1,109 +1,41 @@
import { useTimesheetStore } from "src/stores/timesheet-store"; import { normalizeObject } from "src/utils/normalize-object";
import { useExpenseItems } from "src/modules/timesheets/composables/use-expense-items"; import { useExpensesStore } from "src/stores/expense-store";
import { normalizeExpense, validateExpenseUI } from "../../utils/expenses-validators"; import { expense_validation_schema, type ExpensesApiError } from "src/modules/timesheets/models/expense.validation";
import type { ExpensesApiError } from "src/modules/timesheets/models/expense.validation"; import type { Expense, UpsertExpense } from "src/modules/timesheets/models/expense.models";
import type { Expense, ExpenseType, PayPeriodExpenses } from "src/modules/timesheets/models/expense.models";
const { pay_period } = useTimesheetStore(); export const useExpensesApi = () => {
const expense_items = useExpenseItems(draft); const expenses_store = useExpensesStore();
//PUT by employee_email, year and period no const toUpsertExpense = (obj: {
export const putPayPeriodExpensesByEmployeeEmail = async (employee_email: string, expenses: Expense[]): Promise<PayPeriodExpenses> => { old_expense?: Expense;
const encoded_email = encodeURIComponent(employee_email); new_expense?: Expense;
const encoded_year = encodeURIComponent(String(pay_period.pay_year)); }) => obj as UpsertExpense;
const encoded_pay_period_no = encodeURIComponent(String(pay_period.pay_period_no));
const flat_expenses = expenses.map(expenses): []; const createExpenseByEmployeeEmail = async (employee_email: string): Promise<void> => {
const upsert_expense = toUpsertExpense({
const normalized: Expense[] = plain.map((exp) => { new_expense: normalizeObject(expenses_store.current_expense, expense_validation_schema),
const norm = normalizeExpense(exp as TimesheetExpense);
validateExpenseUI(norm, 'expense_item');
return normalizePayload(norm as unknown as ExpensePayload);
});
const body: UpsertExpensesBody = {expenses: normalized};
try {
const { data } = await api.put<UpsertExpensesResponse>(
// `/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)
: [];
return {
...(data?.data ?? {
pay_period_no,
pay_year,
employee_email: employee_email,
is_approved: false,
expenses: [],
totals: {amount: 0, mileage: 0},
}),
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,
}); });
} await expenses_store.upsertOrDeleteExpensesByEmployeeEmail(employee_email, upsert_expense);
};
export const postPayPeriodExpenses = async (
employee_email: string,
pay_year: number,
pay_period_no: number,
new_expenses: TimesheetExpense[]
): Promise<PayPeriodExpenses> => {
const encoded_email = encodeURIComponent(employee_email);
const encoded_year = encodeURIComponent(String(pay_year));
const encoded_pp = encodeURIComponent(String(pay_period_no));
const plain = Array.isArray(new_expenses) ? new_expenses.map(toPlain) : [];
const normalized: ExpensePayload[] = plain.map((exp) => {
const norm = normalizeExpense(exp as TimesheetExpense);
validateExpenseUI(norm, 'expense_item');
return normalizePayload(norm as unknown as ExpensePayload);
});
const body: UpsertExpensesBody = { expenses: normalized };
try {
const { data } = await api.post<UpsertExpensesResponse>(
`/expenses/${encoded_email}/${encoded_year}/${encoded_pp}`,
body,
{ headers: { 'content-type': 'application/json' } }
);
const items = Array.isArray(data?.data?.expenses)
? data.data.expenses.map(normalizeExpense)
: [];
return {
...(data?.data ?? {
pay_period_no,
pay_year,
employee_email: employee_email,
is_approved: false,
expenses: [],
totals: { amount: 0, mileage: 0 },
}),
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 updateExpenseByEmployeeEmail = async (employee_email: string): Promise<void> => {
const upsert_expense = toUpsertExpense({
old_expense: normalizeObject(expenses_store.initial_expense, expense_validation_schema),
new_expense: normalizeObject(expenses_store.current_expense, expense_validation_schema),
});
await expenses_store.upsertOrDeleteExpensesByEmployeeEmail(employee_email, upsert_expense);
};
const deleteExpenseByEmployeeEmail = async (employee_email: string): Promise<void> => {
const upsert_expense = toUpsertExpense({
old_expense: normalizeObject(expenses_store.initial_expense, expense_validation_schema),
});
await expenses_store.upsertOrDeleteExpensesByEmployeeEmail(employee_email, upsert_expense);
};
return {
createExpenseByEmployeeEmail,
updateExpenseByEmployeeEmail,
deleteExpenseByEmployeeEmail,
};
};

View File

@ -42,7 +42,7 @@ export class ExpensesApiError extends ApiError {
} }
}; };
export const expense_normalizer: Normalizer<Expense> = { export const expense_validation_schema: Normalizer<Expense> = {
date: v => String(v ?? "1970-01-01").trim(), date: v => String(v ?? "1970-01-01").trim(),
type: v => EXPENSE_TYPE.includes(v) ? v as ExpenseType : "EXPENSES", type: v => EXPENSE_TYPE.includes(v) ? v as ExpenseType : "EXPENSES",
amount: v => typeof v === "number" ? v : undefined, amount: v => typeof v === "number" ? v : undefined,

View File

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

View File

@ -5,9 +5,9 @@ export const normExpenseType = (type: unknown): string =>
String(type ?? '').trim().toUpperCase(); String(type ?? '').trim().toUpperCase();
const icon_map: Record<string,string> = { const icon_map: Record<string,string> = {
MILEAGE: 'time_to_leave', MILEAGE: 'time_to_leave',
EXPENSES: 'receipt_long', EXPENSES: 'receipt_long',
PER_DIEM: 'hotel', PER_DIEM: 'hotel',
PRIME_GARDE: 'admin_panel_settings', PRIME_GARDE: 'admin_panel_settings',
}; };
@ -34,11 +34,11 @@ export const computeExpenseTotals = (items: readonly Expense[]): ExpenseTotals =
export const makeExpenseRules = (t: (key: string) => string, max_comment_char: number) => { export const makeExpenseRules = (t: (key: string) => string, max_comment_char: number) => {
const isPresent = (val: unknown) => val !== undefined && val !== null && val !== ''; const isPresent = (val: unknown) => val !== undefined && val !== null && val !== '';
const typeRequired = (val: unknown) => (!!val) || t('timesheet.expense.errors.type_required'); const typeRequired = (val: unknown) => (!!val) || t('timesheet.expense.errors.type_required');
const amountRequired = (val: unknown) => (isPresent(val)) || t('timesheet.expense.errors.amount_required_for_type'); const amountRequired = (val: unknown) => (isPresent(val)) || t('timesheet.expense.errors.amount_required_for_type');
const mileageRequired = (val: unknown) => (isPresent(val)) || t('timesheet.expense.errors.mileage_required_for_type'); const mileageRequired = (val: unknown) => (isPresent(val)) || t('timesheet.expense.errors.mileage_required_for_type');
const commentRequired = (val: unknown) => (String(val ?? '').trim().length > 0) || t('timesheet.expense.errors.comment_required'); const commentRequired = (val: unknown) => (String(val ?? '').trim().length > 0) || t('timesheet.expense.errors.comment_required');
@ -51,14 +51,4 @@ export const makeExpenseRules = (t: (key: string) => string, max_comment_char: n
commentRequired, commentRequired,
commentTooLong, commentTooLong,
}; };
}; };
//------------------ saving payload ------------------
export const buildExpenseSavePayload = (args: PayPeriodExpenses): PayPeriodExpenses => ({
pay_period_no: args.pay_period_no,
pay_year: args.pay_year,
employee_email: args.employee_email,
is_approved: args.is_approved ?? false,
expenses: args.expenses,
totals: computeExpenseTotals(args.expenses),
});

View File

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

View File

@ -1,16 +1,21 @@
import { ref } from "vue"; import { ref } from "vue";
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { useTimesheetStore } from "src/stores/timesheet-store"; 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 { 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"; import { unwrapAndClone } from "src/utils/unwrap-and-clone";
import { timesheetService } from "src/modules/timesheets/services/timesheet-service";
import { ExpensesApiError } from "src/modules/timesheets/models/expense.validation";
const { pay_period } = useTimesheetStore(); const { pay_period } = useTimesheetStore();
const encodeData = ( email: string, year: number, period_number: number ) => {
return { email: encodeURIComponent(email), year: encodeURIComponent(year), period_number: encodeURIComponent(period_number)};
}
export const useExpensesStore = defineStore('expenses', () => { export const useExpensesStore = defineStore('expenses', () => {
const is_open = ref(false); const is_open = ref(false);
const is_loading = ref(false); const is_loading = ref(false);
const current_expenses = ref<PayPeriodExpenses>(default_pay_period_expenses); const pay_period_expenses = ref<PayPeriodExpenses>(default_pay_period_expenses);
const current_expense = ref<Expense>(default_expense); const current_expense = ref<Expense>(default_expense);
const initial_expense = ref<Expense>(default_expense); const initial_expense = ref<Expense>(default_expense);
const error = ref<string | null>(null); const error = ref<string | null>(null);
@ -20,40 +25,26 @@ export const useExpensesStore = defineStore('expenses', () => {
error.value = e?.message || 'Unknown error'; error.value = e?.message || 'Unknown error';
}; };
const open = async (employee_email: string) => { const open = async (employee_email: string): Promise<void> => {
is_open.value = true; is_open.value = true;
is_loading.value = true; is_loading.value = true;
error.value = null; error.value = null;
try { await getPayPeriodExpensesByEmployeeEmail(employee_email);
const response = await getPayPeriodExpenses(employee_email, pay_period.pay_year, pay_period.pay_period_no,); is_loading.value = false;
current_expenses.value = response;
initial_expenses.value = unwrapAndClone(response);
} catch (err) {
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 getPayPeriodExpensesByEmployeeEmail = async (employee_email: string): Promise<void> => {
const encoded_email = encodeURIComponent(employee_email); const encoded_data = encodeData(employee_email, pay_period.pay_year, pay_period.pay_period_no);
const encoded_year = encodeURIComponent(String(pay_period.pay_year));
const encoded_pay_period_no = encodeURIComponent(String(pay_period.pay_period_no));
try { try {
const { data } = await api.get<PayPeriodExpenses>(`/expenses/${encoded_email}/${encoded_year}/${encoded_pay_period_no}`); const expenses = await timesheetService.getExpensesByPayPeriodAndEmployeeEmail(encoded_data.email, encoded_data.year, encoded_data.period_number);
pay_period_expenses.value = expenses;
const items = Array.isArray(data.expenses) ? data.expenses.map(normalizeExpense) : [];
return {
...data,
expenses: items,
};
} catch(err:any) { } catch(err:any) {
const status_code: number = err?.response?.status ?? 500; const status_code: number = err?.response?.status ?? 500;
const data = err?.response?.data ?? {}; const data = err?.response?.data ?? {};
error.value = data.message || data.error || err.message;
throw new ExpensesApiError({ throw new ExpensesApiError({
status_code, status_code,
error_code: data.error_code, error_code: data.error_code,
@ -63,41 +54,16 @@ export const useExpensesStore = defineStore('expenses', () => {
} }
}; };
const onSave = () => { const upsertOrDeleteExpensesByEmployeeEmail = async (employee_email: string, expenses: Expense[]): Promise<void> => {
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; is_loading.value = true;
error.value = null; error.value = null;
try { try {
const updated = await putPayPeriodExpenses(employee_email, pay_period.pay_year, pay_period.pay_period_no, expenses); const encoded_data = encodeData(employee_email, pay_period.pay_year, pay_period.pay_period_no);
pay_period_expenses.value = updated; const payload = { is_approved: false, expenses };
const updated_expenses = await timesheetService.upsertOrDeleteExpensesByPayPeriodAndEmployeeEmail(encoded_data.email, encoded_data.year, encoded_data.period_number, payload);
pay_period_expenses.value.expenses = updated_expenses;
is_open.value = false; is_open.value = false;
} catch (err) { } catch (err) {
setErrorFrom(err); setErrorFrom(err);
@ -114,11 +80,13 @@ export const useExpensesStore = defineStore('expenses', () => {
return { return {
is_open, is_open,
is_loading, is_loading,
current_expenses, pay_period_expenses,
initial_expenses, current_expense,
initial_expense,
error, error,
open, open,
upsertOrDeletePayPeriodExpenseByEmployeeEmail, getPayPeriodExpensesByEmployeeEmail,
upsertOrDeleteExpensesByEmployeeEmail,
close, close,
}; };
}); });