feat(expenses): setup routing for expenses upsert function in form and list

This commit is contained in:
Matthieu Haineault 2025-10-01 14:23:51 -04:00
parent 0388719d42
commit d05634397a
11 changed files with 429 additions and 197 deletions

View File

@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue';
import type { QForm } from 'quasar';
import type { TimesheetExpense } from '../../types/expense.interfaces'; import type { TimesheetExpense } from '../../types/expense.interfaces';
import type { ExpenseType } from '../../types/expense.types'; import type { ExpenseType } from '../../types/expense.types';
/* eslint-disable */ /* eslint-disable */
@ -8,8 +10,8 @@ const draft = defineModel<Partial<TimesheetExpense>>('draft');
const files = defineModel<File[] | null>('files'); const files = defineModel<File[] | null>('files');
const datePickerOpen = defineModel<boolean | null>('datePickerOpen', {default: false }); const datePickerOpen = defineModel<boolean | null>('datePickerOpen', {default: false });
//------------------ props ------------------ //------------------ Props ------------------
const props = defineProps<{ const {setType} = 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;
@ -24,18 +26,30 @@ const props = defineProps<{
setType: (val: ExpenseType) => void; setType: (val: ExpenseType) => void;
}>(); }>();
//------------------ emits ------------------ //------------------ Emits ------------------
const emit = defineEmits<{ defineEmits<{
(e: 'submit'): void; 'submit': [void];
}>(); }>();
//------------------ Exposes ------------------
const inner_form = ref<QForm | null>(null);
defineExpose({
validate: async ( force = true ) => (await inner_form.value?.validate(force)) === true,
});
//------------------ Handlers ------------------
const onTypeChange = (val: ExpenseType) => {
setType(val);
};
</script> </script>
<template> <template>
<q-form <q-form
ref="inner_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 +58,7 @@ const emit = defineEmits<{
<!-- date selection input --> <!-- date selection input -->
<q-input <q-input
v-model.date="draft!.date" v-model="draft!.date"
dense dense
filled filled
readonly readonly
@ -141,7 +155,7 @@ const emit = defineEmits<{
> >
<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) }} $
@ -80,6 +86,19 @@ const emit = defineEmits<{
</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>
<q-btn <q-btn
@ -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

@ -1,68 +1,76 @@
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { useExpenseForm } from '../../composables/use-expense-form'; import { useExpenseForm } from '../../composables/use-expense-form';
import { useExpenseDraft } from '../../composables/use-expense-draft'; import { useExpenseDraft } from '../../composables/use-expense-draft';
import { useExpenseItems } from '../../composables/use-expense-items'; 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 { useToggle } from 'src/modules/shared/composables/use-toggle'; import ExpenseList from './expense-list.vue';
import ExpenseList from './expense-list.vue'; import ExpenseForm from './expense-form.vue';
import ExpenseForm from './expense-form.vue';
import { import {
buildExpenseTypeOptions, buildExpenseTypeOptions,
computeExpenseTotals, computeExpenseTotals,
makeExpenseRules, makeExpenseRules,
buildExpenseSavePayload buildExpenseSavePayload
} from '../../utils/expense.util'; } from '../../utils/expense.util';
import { EXPENSE_TYPE } from '../../types/expense.types'; import { COMMENT_MAX_LENGTH } from '../../constants/expense.constants';
import { ExpensesValidationError } from '../../types/expense-validation.interface'; import { ExpensesValidationError } from '../../types/expense-validation.interface';
import type { TimesheetExpense } from '../../types/expense.interfaces'; 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 */ /* eslint-disable */
const { t , locale } = useI18n(); const { t, locale } = useI18n();
const rules = makeExpenseRules(t, COMMENT_MAX_LENGTH); const rules = makeExpenseRules(t, COMMENT_MAX_LENGTH);
//------------------ props ------------------ //------------------ props ------------------
const props = defineProps<{ const {email, pay_period_no, pay_year, is_approved, initial_expenses} = defineProps<{
pay_period_no: number; pay_period_no: number;
pay_year: number; pay_year: number;
email: string; email: string;
is_approved?: boolean; is_approved?: boolean;
initial_expenses?: TimesheetExpense[]; initial_expenses?: TimesheetExpense[];
}>(); }>();
//------------------ emits ------------------ //------------------ emits ------------------
const emit = defineEmits<{ const emit = defineEmits<{
(e:'close'): void; (e: 'close'): void;
(e: 'save', payload: { (e: 'save', payload: {
pay_period_no: number; pay_period_no: number;
pay_year: number; pay_year: number;
email: string; email: string;
expenses: TimesheetExpense[]; expenses: TimesheetExpense[];
}): void; }): void;
(e: 'error', err: ExpensesValidationError): void; (e: 'error', err: ExpensesValidationError): void;
}>(); }>();
//------------------ q-select mapper ------------------ //------------------ q-select mapper ------------------
const type_options = computed(()=> { const type_options = computed(() => {
void locale.value; void locale.value;
return buildExpenseTypeOptions(EXPENSE_TYPE, t); return buildExpenseTypeOptions(EXPENSE_TYPE, t);
}) })
//------------------ refs and computed ------------------ //------------------ refs and computed ------------------
const files = ref<File[] | null>(null); const files = ref<File[] | null>(null);
const is_readonly = computed(()=> !!props.is_approved); const is_readonly = computed(() => !!is_approved);
const editing_old = ref<ExpenseDay | null>(null);
const { state: is_open_date_picker } = useToggle(); const { state: is_open_date_picker } = useToggle();
const { draft, setType, reset, showAmount } = useExpenseDraft(); const { draft, setType, reset, showAmount } = useExpenseDraft();
const { validateAnd } = useExpenseForm(); const { formRef, validateAnd } = useExpenseForm();
const { items, addFromDraft, removeAt, validateAll, payload } = useExpenseItems({ const { items, validateAll, payload } = useExpenseItems({
initial_expenses: props.initial_expenses, initial_expenses: initial_expenses,
is_approved: is_readonly,
draft, draft,
is_approved: is_readonly,
}); });
const totals = computed(()=> computeExpenseTotals(items.value)); const totals = computed(() => computeExpenseTotals(items.value));
//------------------ actions ------------------ //------------------ actions ------------------
const onSave = () => { const onSave = () => {
@ -70,29 +78,12 @@ const onSave = () => {
validateAll(); validateAll();
reset(); reset();
emit('save', buildExpenseSavePayload({ emit('save', buildExpenseSavePayload({
pay_period_no: props.pay_period_no, pay_period_no: pay_period_no,
pay_year: props.pay_year, pay_year: pay_year,
email: props.email, email: email,
expenses: payload(), 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) { } catch (err: any) {
const e = err instanceof ExpensesValidationError const e = err instanceof ExpensesValidationError
? err ? err
@ -104,6 +95,105 @@ const onFormSubmit = async () => {
} }
}; };
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'); const onClose = () => emit('close');
</script> </script>
@ -112,21 +202,34 @@ const onClose = () => emit('close');
<div> <div>
<!-- header (title with totals)--> <!-- header (title with totals)-->
<q-item class="row justify-between"> <q-item class="row justify-between">
<q-item-label header class="text-h6 col-auto"> <q-item-label
header
class="text-h6 col-auto"
>
{{ $t('timesheet.expense.title') }} {{ $t('timesheet.expense.title') }}
</q-item-label> </q-item-label>
<q-item-section class="items-center col-auto"> <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-badge
<q-separator spaced/> lines="1"
<q-badge lines="2" class="q-pa-sm q-px-md" :label="$t('timesheet.expense.total_mileage') + ': ' + totals.mileage.toFixed(1)"/> class="q-pa-sm q-px-md"
</q-item-section> :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> </q-item>
<ExpenseList <ExpenseList
:items="items" :items="items"
:is_readonly="is_readonly" :is_readonly="is_readonly"
@remove="removeAt" @remove="onRemove"
@edit="onEdit"
/> />
<ExpenseForm <ExpenseForm
ref="formRef"
v-model:draft="draft" v-model:draft="draft"
v-model:files="files" v-model:files="files"
v-model:date-picker-open="is_open_date_picker" v-model:date-picker-open="is_open_date_picker"
@ -139,7 +242,7 @@ const onClose = () => emit('close');
@submit="onFormSubmit" @submit="onFormSubmit"
/> />
<q-separator spaced/> <q-separator spaced />
<div class="row col-auto justify-end"> <div class="row col-auto justify-end">
<!-- close btn --> <!-- close btn -->

View File

@ -3,18 +3,18 @@
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { SHIFT_KEY, type ShiftKey, type ShiftPayload, type ShiftSelectOption } from '../../types/shift.types'; import { SHIFT_KEY, type ShiftKey, type ShiftPayload, type ShiftSelectOption } from '../../types/shift.types';
import type { UpsertShiftsBody } from '../../types/shift.interfaces'; import type { UpsertShiftsBody } from '../../types/shift.interfaces';
import { upsertShiftsByDate } from '../../composables/api/use-shift-api'; import { upsertShiftsByDate } from '../../composables/api/use-shift-api';
/* eslint-disable */ /* eslint-disable */
const { t } = useI18n(); const { t } = useI18n();
const props = defineProps<{ const props = defineProps<{
mode: 'create' | 'edit' | 'delete'; mode: 'create' | 'edit' | 'delete';
dateIso: string; dateIso: string;
initialShift?: ShiftPayload | null; initialShift?: ShiftPayload | null;
shiftOptions: ShiftSelectOption[]; shiftOptions: ShiftSelectOption[];
email: string; email: string;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@ -23,41 +23,41 @@ const emit = defineEmits<{
}>(); }>();
const isSubmitting = ref(false); const isSubmitting = ref(false);
const errorBanner = ref<string | null>(null); const errorBanner = ref<string | null>(null);
const conflicts = ref<Array<{start_time: string; end_time: string; type: string}>>([]); const conflicts = ref<Array<{ start_time: string; end_time: string; type: string }>>([]);
const opened = defineModel<boolean> ( { default: false }); const opened = defineModel<boolean>({ default: false });
const startTime = defineModel<string> ('startTime', { default: '' }); const startTime = defineModel<string>('startTime', { default: '' });
const endTime = defineModel<string> ('endTime' , { default: '' }); const endTime = defineModel<string>('endTime', { default: '' });
const type = defineModel<ShiftKey | ''> ('type' , { default: '' }); const type = defineModel<ShiftKey | ''>('type', { default: '' });
const isRemote = defineModel<boolean> ('isRemote' , { default: false }); const isRemote = defineModel<boolean>('isRemote', { default: false });
const comment = defineModel<string> ('comment' , { default: '' }); const comment = defineModel<string>('comment', { default: '' });
const isShiftKey = (val: unknown): val is ShiftKey => SHIFT_KEY.includes(val as ShiftKey); const isShiftKey = (val: unknown): val is ShiftKey => SHIFT_KEY.includes(val as ShiftKey);
const buildNewShiftPayload = (): ShiftPayload => { const buildNewShiftPayload = (): ShiftPayload => {
if(!isShiftKey(type.value)) throw new Error('Invalid shift type'); if (!isShiftKey(type.value)) throw new Error('Invalid shift type');
const trimmed = (comment.value ?? '').trim(); const trimmed = (comment.value ?? '').trim();
return { return {
start_time: startTime.value, start_time: startTime.value,
end_time: endTime.value, end_time: endTime.value,
type: type.value, type: type.value,
is_remote: isRemote.value, is_remote: isRemote.value,
...(trimmed ? { comment: trimmed } : {}), ...(trimmed ? { comment: trimmed } : {}),
}; };
}; };
const onSubmit = async () => { const onSubmit = async () => {
errorBanner.value = null; errorBanner.value = null;
conflicts.value = []; conflicts.value = [];
isSubmitting.value = true; isSubmitting.value = true;
try{ try {
let body: UpsertShiftsBody; let body: UpsertShiftsBody;
if(props.mode === 'create') { if (props.mode === 'create') {
body = { new_shift: buildNewShiftPayload() }; body = { new_shift: buildNewShiftPayload() };
} else if (props.mode === 'edit') { } else if (props.mode === 'edit') {
if(!props.initialShift) throw new Error('Missing initial Shift for edit'); if (!props.initialShift) throw new Error('Missing initial Shift for edit');
body = { old_shift: props.initialShift, new_shift: buildNewShiftPayload() }; body = { old_shift: props.initialShift, new_shift: buildNewShiftPayload() };
} else { } else {
if (!props.initialShift) throw new Error('Missing initial Shift for delete.'); if (!props.initialShift) throw new Error('Missing initial Shift for delete.');
@ -70,11 +70,11 @@ const onSubmit = async () => {
const status = error?.status_code ?? error.response?.status ?? 500; const status = error?.status_code ?? error.response?.status ?? 500;
const apiConflicts = error?.response?.data?.conflicts ?? error?.data?.conflicts; const apiConflicts = error?.response?.data?.conflicts ?? error?.data?.conflicts;
if(Array.isArray(apiConflicts)){ if (Array.isArray(apiConflicts)) {
conflicts.value = apiConflicts.map((c:any)=> ({ conflicts.value = apiConflicts.map((c: any) => ({
start_time: String(c.start_time ?? ''), start_time: String(c.start_time ?? ''),
end_time: String(c.end_time ?? ''), end_time: String(c.end_time ?? ''),
type: String(c.type ?? ''), type: String(c.type ?? ''),
})); }));
} else { } else {
conflicts.value = []; conflicts.value = [];
@ -84,81 +84,92 @@ const onSubmit = async () => {
else if (status === 409) errorBanner.value = t('timesheet.shift.errors.overlap') else if (status === 409) errorBanner.value = t('timesheet.shift.errors.overlap')
else if (status === 422) errorBanner.value = t('timesheet.shift.errors.invalid') else if (status === 422) errorBanner.value = t('timesheet.shift.errors.invalid')
else errorBanner.value = t('timesheet.shift.errors.unknown') else errorBanner.value = t('timesheet.shift.errors.unknown')
//add conflicts.value error management //add conflicts.value error management
} finally { } finally {
isSubmitting.value = false; isSubmitting.value = false;
} }
} }
const hydrateFromProps = () => { const hydrateFromProps = () => {
if(props.mode === 'edit' || props.mode === 'delete') { if (props.mode === 'edit' || props.mode === 'delete') {
const shift = props.initialShift; const shift = props.initialShift;
startTime.value = shift?.start_time ?? ''; startTime.value = shift?.start_time ?? '';
endTime.value = shift?.end_time ?? ''; endTime.value = shift?.end_time ?? '';
type.value = shift?.type ?? ''; type.value = shift?.type ?? '';
isRemote.value = !!shift?.is_remote; isRemote.value = !!shift?.is_remote;
comment.value = (shift as any)?.comment ?? ''; comment.value = (shift as any)?.comment ?? '';
} else { } else {
startTime.value = ''; startTime.value = '';
endTime.value = ''; endTime.value = '';
type.value = ''; type.value = '';
isRemote.value = false; isRemote.value = false;
comment.value = ''; comment.value = '';
} }
}; };
const canSubmit = computed(() => const canSubmit = computed(() =>
props.mode === 'delete' || props.mode === 'delete' ||
(startTime.value.trim().length === 5 && (startTime.value.trim().length === 5 &&
endTime.value.trim().length === 5 && endTime.value.trim().length === 5 &&
isShiftKey(type.value)) isShiftKey(type.value))
); );
watch( watch(
()=> [opened.value, props.mode, props.initialShift, props.dateIso], () => [opened.value, props.mode, props.initialShift, props.dateIso],
()=> { if (opened.value) hydrateFromProps();}, () => { if (opened.value) hydrateFromProps(); },
{ immediate: true } { immediate: true }
); );
</script> </script>
<!-- create/edit/delete shifts dialog --> <!-- create/edit/delete shifts dialog -->
<template> <template>
<q-dialog v-model="opened" <q-dialog
persistent v-model="opened"
transition-show="fade" persistent
transition-hide="fade"> transition-show="fade"
transition-hide="fade"
>
<q-card class="q-pa-md"> <q-card class="q-pa-md">
<div class="row items-center q-mb-sm"> <div class="row items-center q-mb-sm">
<q-icon name="schedule" <q-icon
size="24px" name="schedule"
class="q-mr-sm"/> size="24px"
class="q-mr-sm"
/>
<div class="text-h6"> <div class="text-h6">
{{ {{
props.mode === 'create' props.mode === 'create'
? $t('timesheet.shift.actions.add') ? $t('timesheet.shift.actions.add')
: props.mode === 'edit' : props.mode === 'edit'
? $t('timesheet.shift.actions.edit') ? $t('timesheet.shift.actions.edit')
: $t('timesheet.shift.actions.delete') : $t('timesheet.shift.actions.delete')
}} }}
</div> </div>
<q-space/> <q-space />
<q-badge outline color="primary"> <q-badge
outline
color="primary"
>
{{ props.dateIso }} {{ props.dateIso }}
</q-badge> </q-badge>
</div> </div>
<q-separator spaced/> <q-separator spaced />
<div v-if="props.mode !== 'delete'" class="column q-gutter-md"> <div
v-if="props.mode !== 'delete'"
class="column q-gutter-md"
>
<div class="row "> <div class="row ">
<div class="col"> <div class="col">
<q-input <q-input
v-model="startTime" v-model="startTime"
:label="$t('timesheet.shift.fields.start')" :label="$t('timesheet.shift.fields.start')"
filled dense filled
dense
inputmode="numeric" inputmode="numeric"
mask="##:##" mask="##:##"
/> />
@ -167,7 +178,8 @@ watch(
<q-input <q-input
v-model="endTime" v-model="endTime"
:label="$t('timesheet.shift.fields.end')" :label="$t('timesheet.shift.fields.end')"
filled dense filled
dense
inputmode="numeric" inputmode="numeric"
mask="##:##" mask="##:##"
/> />
@ -181,7 +193,8 @@ watch(
:label="$t('timesheet.shift.types.label')" :label="$t('timesheet.shift.types.label')"
class="col" class="col"
color="primary" color="primary"
filled dense filled
dense
hide-dropdown-icon hide-dropdown-icon
emit-value emit-value
map-options map-options
@ -189,27 +202,46 @@ watch(
<q-toggle <q-toggle
v-model="isRemote" v-model="isRemote"
:label="$t('timesheet.shift.types.REMOTE')" :label="$t('timesheet.shift.types.REMOTE')"
class="col-auto" /> class="col-auto"
/>
</div> </div>
<q-input <q-input
v-model="comment" v-model="comment"
type="textarea" type="textarea"
autogrow filled dense autogrow
filled
dense
:label="$t('timesheet.shift.fields.header_comment')" :label="$t('timesheet.shift.fields.header_comment')"
:counter="true" :maxlength="512" :counter="true"
:maxlength="512"
/> />
</div> </div>
<div v-else class="q-pa-md"> <div
v-else
class="q-pa-md"
>
{{ $t('timesheet.shift.actions.delete_confirmation_msg') }} {{ $t('timesheet.shift.actions.delete_confirmation_msg') }}
</div> </div>
<div v-if="errorBanner" class="q-mt-md"> <div
<q-banner dense class="bg-red-2 text-negative">{{ errorBanner }}</q-banner> v-if="errorBanner"
<div v-if="conflicts.length" class="q-mt-xs"> class="q-mt-md"
>
<q-banner
dense
class="bg-red-2 text-negative"
>{{ errorBanner }}</q-banner>
<div
v-if="conflicts.length"
class="q-mt-xs"
>
<div class="text-caption">Conflits :</div> <div class="text-caption">Conflits :</div>
<ul class="q-pl-md q-mt-xs"> <ul class="q-pl-md q-mt-xs">
<li v-for="(c, i) in conflicts" :key="i"> <li
v-for="(c, i) in conflicts"
:key="i"
>
{{ c.start_time }}{{ c.end_time }} ({{ c.type }}) {{ c.start_time }}{{ c.end_time }} ({{ c.type }})
</li> </li>
</ul> </ul>
@ -223,24 +255,27 @@ watch(
flat flat
color="grey-8" color="grey-8"
:label="$t('timesheet.cancel_button')" :label="$t('timesheet.cancel_button')"
@click="() => { opened = false; emit('close');}" @click="() => { opened = false; emit('close'); }"
/> />
<q-btn <q-btn
v-if="props.mode === 'delete'" v-if="props.mode === 'delete'"
outline color="negative" outline
color="negative"
icon="cancel" icon="cancel"
:label="$t('timesheet.delete_button')" :label="$t('timesheet.delete_button')"
:loading="isSubmitting" :loading="isSubmitting"
:disable="!canSubmit" :disable="!canSubmit"
@click="onSubmit" @click="onSubmit"
/> />
<q-btn v-else <q-btn
color="primary" v-else
icon="save_alt" color="primary"
:label="$t('timesheet.save_button')" icon="save_alt"
:loading="isSubmitting" :label="$t('timesheet.save_button')"
:disable="!canSubmit" :loading="isSubmitting"
@click="onSubmit"/> :disable="!canSubmit"
@click="onSubmit"
/>
</div> </div>
</q-card> </q-card>
</q-dialog> </q-dialog>

View File

@ -4,11 +4,12 @@ import { normalizeExpense, validateExpenseUI } from "../../utils/expenses-valida
import type { ExpenseType } from "../../types/expense.types"; import type { ExpenseType } from "../../types/expense.types";
import { ExpensesApiError } from "../../types/expense-validation.interface"; import { ExpensesApiError } from "../../types/expense-validation.interface";
import type { import type {
ExpensePayload, ExpensePayload,
PayPeriodExpenses, PayPeriodExpenses,
TimesheetExpense, TimesheetExpense,
UpsertExpensesBody, UpsertExpenseResult,
UpsertExpensesResponse UpsertExpensesBody,
UpsertExpensesResponse
} from "../../types/expense.interfaces"; } from "../../types/expense.interfaces";
/* eslint-disable */ /* eslint-disable */
@ -167,3 +168,58 @@ export const postPayPeriodExpenses = async (
} }
}; };
const resolveDateISO = (a?: ExpensePayload, b?: ExpensePayload): string => {
const d = a?.date || b?.date;
if(!d) throw new Error('date is required in payload');
return d;
};
const makeBody = (obj: {
old_expense?: ExpensePayload;
new_expense?: ExpensePayload;
}) => obj;
const postUpsert = async (email: string, date: string, body: {
old_expense?: ExpensePayload;
new_expense?: ExpensePayload;
}): Promise<UpsertExpenseResult> => {
try {
const url = `/expenses/upsert/${encodeURIComponent(email)}/${date}`;
const { data } = await api.post<UpsertExpenseResult>(url, body, {
headers: { 'Content-Type': 'application/json'},
});
return data;
} 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,
});
}
};
//create a new expense
export const createExpenseByDate = async ( email: string, payload: ExpensePayload): Promise<UpsertExpenseResult> => {
const new_expense = normalizePayload(payload);
const date = resolveDateISO(new_expense);
return postUpsert(email, date, makeBody({ new_expense: new_expense }));
};
//update an expense
export const updateExpenseByDate = async ( email: string, old_expense: ExpensePayload, new_expense: ExpensePayload): Promise<UpsertExpenseResult> => {
const old_exp = normalizePayload(old_expense);
const new_exp = normalizePayload(new_expense);
const date = resolveDateISO(new_exp, old_exp);
return postUpsert(email, date, makeBody({ old_expense: old_exp,new_expense: new_exp }));
};
//delete an expense
export const deleteExpenseByDate = async (email: string, old_expense: ExpensePayload): Promise<UpsertExpenseResult> => {
const old = normalizePayload(old_expense);
const date = resolveDateISO(undefined, old);
return postUpsert(email, date, makeBody({ old_expense: old }));
};

View File

@ -30,6 +30,7 @@ export interface PayPeriodExpenses {
} }
} }
//used by expenses form, either amount or mileage, not both will be sent to the backend
export interface ExpensePayload{ export interface ExpensePayload{
date: string; date: string;
type: ExpenseType; type: ExpenseType;
@ -38,6 +39,16 @@ export interface ExpensePayload{
comment: string; comment: string;
} }
//amount is required since mileage is returned in $ ( km * modifier )
export interface ExpenseDay{
date: string;
type: ExpenseType;
amount: number;
mileage?: number;
comment: string;
is_approved: boolean;
}
export interface UpsertExpensesBody { export interface UpsertExpensesBody {
expenses: ExpensePayload[]; expenses: ExpensePayload[];
} }
@ -45,3 +56,8 @@ export interface UpsertExpensesBody {
export interface UpsertExpensesResponse { export interface UpsertExpensesResponse {
data: PayPeriodExpenses; data: PayPeriodExpenses;
} }
export interface UpsertExpenseResult {
action: 'created' | 'updated' | 'deleted';
day: ExpenseDay[];
}

View File

@ -1,4 +1,5 @@
import type { ShiftKey, ShiftPayload, UpsertAction } from "./shift.types"; import type { ShiftKey, ShiftPayload } from "./shift.types";
import type { UpsertAction } from "./ui.types";
export interface Shift { export interface Shift {
date: string; date: string;

View File

@ -25,6 +25,3 @@ export type ShiftLegendItem = {
label_key: string; label_key: string;
text_color?: string; text_color?: string;
}; };
export type UpsertAction = 'created' | 'updated' | 'deleted';

View File

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

View File

@ -6,9 +6,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',
}; };
@ -43,11 +43,11 @@ export const computeExpenseTotals = (items: readonly TimesheetExpense[]): Expens
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.erros.mileage_required_for_type'); const mileageRequired = (val: unknown) => (isPresent(val)) || t('timesheet.expense.errors.mileage_required_for_type');
const commentRequired = (val: unknown) => (String(val ?? '').trim().length > 0) || t('timesheet.expense.errors.comment_required'); const commentRequired = (val: unknown) => (String(val ?? '').trim().length > 0) || t('timesheet.expense.errors.comment_required');

View File

@ -115,14 +115,3 @@ export const validateExpenseUI = (raw: TimesheetExpense, label: string = 'expens
}); });
} }
}; };
//totals per pay-period
export const compute_expense_totals = (items: TimesheetExpense[]) => items.reduce(
(acc, raw) => {
const expense = normalizeExpense(raw);
if(typeof expense.amount === 'number' && expense.amount > 0) acc.amount += expense.amount;
if(typeof expense.mileage === 'number' && expense.mileage > 0) acc.mileage += expense.mileage;
return acc;
},
{ amount: 0, mileage: 0 }
);