feat(expenses): setup routing for expenses upsert function in form and list
This commit is contained in:
parent
0388719d42
commit
d05634397a
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 -->
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 }));
|
||||||
|
};
|
||||||
|
|
@ -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[];
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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';
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -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');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
|
||||||
);
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user