Merge pull request 'dev/matthieu/timesheet-form' (#20) from dev/matthieu/timesheet-form into main
Reviewed-on: Targo/targo_frontend#20
This commit is contained in:
commit
1bdbe021fa
|
|
@ -1,4 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import type { QForm } from 'quasar';
|
||||
import type { TimesheetExpense } from '../../types/expense.interfaces';
|
||||
import type { ExpenseType } from '../../types/expense.types';
|
||||
/* eslint-disable */
|
||||
|
|
@ -8,8 +10,8 @@ const draft = defineModel<Partial<TimesheetExpense>>('draft');
|
|||
const files = defineModel<File[] | null>('files');
|
||||
const datePickerOpen = defineModel<boolean | null>('datePickerOpen', {default: false });
|
||||
|
||||
//------------------ props ------------------
|
||||
const props = defineProps<{
|
||||
//------------------ Props ------------------
|
||||
const {setType} = defineProps<{
|
||||
type_options: { label: string; value: ExpenseType }[];
|
||||
show_amount: boolean;
|
||||
is_readonly: boolean;
|
||||
|
|
@ -24,18 +26,30 @@ const props = defineProps<{
|
|||
setType: (val: ExpenseType) => void;
|
||||
}>();
|
||||
|
||||
//------------------ emits ------------------
|
||||
const emit = defineEmits<{
|
||||
(e: 'submit'): void;
|
||||
//------------------ Emits ------------------
|
||||
defineEmits<{
|
||||
'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>
|
||||
|
||||
<template>
|
||||
<q-form
|
||||
ref="inner_form"
|
||||
flat
|
||||
v-if="!is_readonly"
|
||||
@submit.prevent="emit('submit')"
|
||||
@submit.prevent="$emit('submit')"
|
||||
>
|
||||
<div class="text-subtitle2 q-py-sm">
|
||||
{{ $t('timesheet.expense.add_expense')}}
|
||||
|
|
@ -44,7 +58,7 @@ const emit = defineEmits<{
|
|||
|
||||
<!-- date selection input -->
|
||||
<q-input
|
||||
v-model.date="draft!.date"
|
||||
v-model="draft!.date"
|
||||
dense
|
||||
filled
|
||||
readonly
|
||||
|
|
@ -141,7 +155,7 @@ const emit = defineEmits<{
|
|||
>
|
||||
<template #label>
|
||||
<span class="text-weight-bold ">
|
||||
{{ $t('timesheet.expense.employee_comment') }}
|
||||
{{ $t('timesheet.expense.comment') }}
|
||||
</span>
|
||||
</template>
|
||||
</q-input>
|
||||
|
|
|
|||
|
|
@ -2,13 +2,14 @@
|
|||
import type { TimesheetExpense } from '../../types/expense.interfaces';
|
||||
import { expenseTypeIcon } from '../../utils/expense.util';
|
||||
/* eslint-disable */
|
||||
const props = defineProps<{
|
||||
defineProps<{
|
||||
items: TimesheetExpense[];
|
||||
is_readonly: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
defineEmits<{
|
||||
(e: 'remove', index: number): void;
|
||||
(e: 'edit' , index: number): void;
|
||||
}>();
|
||||
|
||||
</script>
|
||||
|
|
@ -36,7 +37,12 @@ const emit = defineEmits<{
|
|||
<!-- amount or mileage section -->
|
||||
<q-item-section top>
|
||||
<q-item-label v-if="String(expense.type).trim().toUpperCase() === 'MILEAGE'">
|
||||
<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 v-else>
|
||||
{{ expense.amount?.toFixed(2) }} $
|
||||
|
|
@ -80,6 +86,19 @@ const emit = defineEmits<{
|
|||
</q-item-label>
|
||||
</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 -->
|
||||
<q-item-section side>
|
||||
<q-btn
|
||||
|
|
@ -89,7 +108,7 @@ const emit = defineEmits<{
|
|||
size="xs"
|
||||
color="negative"
|
||||
icon="close"
|
||||
@click="emit('remove', index)"
|
||||
@click="$emit('remove', index)"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { computed, ref } from 'vue';
|
|||
import { useExpenseForm } from '../../composables/use-expense-form';
|
||||
import { useExpenseDraft } from '../../composables/use-expense-draft';
|
||||
import { useExpenseItems } from '../../composables/use-expense-items';
|
||||
import { COMMENT_MAX_LENGTH } from '../../constants/expense.constants';
|
||||
import { useToggle } from 'src/modules/shared/composables/use-toggle';
|
||||
import ExpenseList from './expense-list.vue';
|
||||
import ExpenseForm from './expense-form.vue';
|
||||
|
|
@ -14,9 +13,17 @@ import {
|
|||
makeExpenseRules,
|
||||
buildExpenseSavePayload
|
||||
} 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 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 */
|
||||
|
||||
|
|
@ -24,7 +31,7 @@ const { t , locale } = useI18n();
|
|||
const rules = makeExpenseRules(t, COMMENT_MAX_LENGTH);
|
||||
|
||||
//------------------ props ------------------
|
||||
const props = defineProps<{
|
||||
const {email, pay_period_no, pay_year, is_approved, initial_expenses} = defineProps<{
|
||||
pay_period_no: number;
|
||||
pay_year: number;
|
||||
email: string;
|
||||
|
|
@ -52,15 +59,16 @@ const type_options = computed(()=> {
|
|||
|
||||
//------------------ refs and computed ------------------
|
||||
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 { draft, setType, reset, showAmount } = useExpenseDraft();
|
||||
const { validateAnd } = useExpenseForm();
|
||||
const { items, addFromDraft, removeAt, validateAll, payload } = useExpenseItems({
|
||||
initial_expenses: props.initial_expenses,
|
||||
draft,
|
||||
const { formRef, validateAnd } = useExpenseForm();
|
||||
const { items, validateAll, payload } = useExpenseItems({
|
||||
initial_expenses: initial_expenses,
|
||||
is_approved: is_readonly,
|
||||
draft,
|
||||
});
|
||||
const totals = computed(() => computeExpenseTotals(items.value));
|
||||
|
||||
|
|
@ -70,9 +78,9 @@ const onSave = () => {
|
|||
validateAll();
|
||||
reset();
|
||||
emit('save', buildExpenseSavePayload({
|
||||
pay_period_no: props.pay_period_no,
|
||||
pay_year: props.pay_year,
|
||||
email: props.email,
|
||||
pay_period_no: pay_period_no,
|
||||
pay_year: pay_year,
|
||||
email: email,
|
||||
expenses: payload(),
|
||||
}));
|
||||
|
||||
|
|
@ -87,16 +95,43 @@ const onSave = () => {
|
|||
}
|
||||
};
|
||||
|
||||
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 () => {
|
||||
addFromDraft();
|
||||
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({
|
||||
const e = err instanceof ExpensesValidationError ? err : new ExpensesValidationError({
|
||||
status_code: 400,
|
||||
message: String(err?.message || err)
|
||||
});
|
||||
|
|
@ -104,6 +139,61 @@ const onFormSubmit = async () => {
|
|||
}
|
||||
};
|
||||
|
||||
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>
|
||||
|
|
@ -112,21 +202,34 @@ const onClose = () => emit('close');
|
|||
<div>
|
||||
<!-- header (title with totals)-->
|
||||
<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') }}
|
||||
</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-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-badge
|
||||
lines="2"
|
||||
class="q-pa-sm q-px-md"
|
||||
:label="$t('timesheet.expense.total_mileage') + ': ' + totals.mileage.toFixed(1)"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<ExpenseList
|
||||
:items="items"
|
||||
:is_readonly="is_readonly"
|
||||
@remove="removeAt"
|
||||
@remove="onRemove"
|
||||
@edit="onEdit"
|
||||
/>
|
||||
<ExpenseForm
|
||||
ref="formRef"
|
||||
v-model:draft="draft"
|
||||
v-model:files="files"
|
||||
v-model:date-picker-open="is_open_date_picker"
|
||||
|
|
|
|||
|
|
@ -125,16 +125,20 @@ watch(
|
|||
|
||||
<!-- create/edit/delete shifts dialog -->
|
||||
<template>
|
||||
<q-dialog v-model="opened"
|
||||
<q-dialog
|
||||
v-model="opened"
|
||||
persistent
|
||||
transition-show="fade"
|
||||
transition-hide="fade">
|
||||
transition-hide="fade"
|
||||
>
|
||||
|
||||
<q-card class="q-pa-md">
|
||||
<div class="row items-center q-mb-sm">
|
||||
<q-icon name="schedule"
|
||||
<q-icon
|
||||
name="schedule"
|
||||
size="24px"
|
||||
class="q-mr-sm"/>
|
||||
class="q-mr-sm"
|
||||
/>
|
||||
<div class="text-h6">
|
||||
{{
|
||||
props.mode === 'create'
|
||||
|
|
@ -145,20 +149,27 @@ watch(
|
|||
}}
|
||||
</div>
|
||||
<q-space />
|
||||
<q-badge outline color="primary">
|
||||
<q-badge
|
||||
outline
|
||||
color="primary"
|
||||
>
|
||||
{{ props.dateIso }}
|
||||
</q-badge>
|
||||
</div>
|
||||
|
||||
<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="col">
|
||||
<q-input
|
||||
v-model="startTime"
|
||||
:label="$t('timesheet.shift.fields.start')"
|
||||
filled dense
|
||||
filled
|
||||
dense
|
||||
inputmode="numeric"
|
||||
mask="##:##"
|
||||
/>
|
||||
|
|
@ -167,7 +178,8 @@ watch(
|
|||
<q-input
|
||||
v-model="endTime"
|
||||
:label="$t('timesheet.shift.fields.end')"
|
||||
filled dense
|
||||
filled
|
||||
dense
|
||||
inputmode="numeric"
|
||||
mask="##:##"
|
||||
/>
|
||||
|
|
@ -181,7 +193,8 @@ watch(
|
|||
:label="$t('timesheet.shift.types.label')"
|
||||
class="col"
|
||||
color="primary"
|
||||
filled dense
|
||||
filled
|
||||
dense
|
||||
hide-dropdown-icon
|
||||
emit-value
|
||||
map-options
|
||||
|
|
@ -189,27 +202,46 @@ watch(
|
|||
<q-toggle
|
||||
v-model="isRemote"
|
||||
:label="$t('timesheet.shift.types.REMOTE')"
|
||||
class="col-auto" />
|
||||
class="col-auto"
|
||||
/>
|
||||
</div>
|
||||
<q-input
|
||||
v-model="comment"
|
||||
type="textarea"
|
||||
autogrow filled dense
|
||||
autogrow
|
||||
filled
|
||||
dense
|
||||
:label="$t('timesheet.shift.fields.header_comment')"
|
||||
:counter="true" :maxlength="512"
|
||||
:counter="true"
|
||||
:maxlength="512"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else class="q-pa-md">
|
||||
<div
|
||||
v-else
|
||||
class="q-pa-md"
|
||||
>
|
||||
{{ $t('timesheet.shift.actions.delete_confirmation_msg') }}
|
||||
</div>
|
||||
|
||||
<div v-if="errorBanner" class="q-mt-md">
|
||||
<q-banner dense class="bg-red-2 text-negative">{{ errorBanner }}</q-banner>
|
||||
<div v-if="conflicts.length" class="q-mt-xs">
|
||||
<div
|
||||
v-if="errorBanner"
|
||||
class="q-mt-md"
|
||||
>
|
||||
<q-banner
|
||||
dense
|
||||
class="bg-red-2 text-negative"
|
||||
>{{ errorBanner }}</q-banner>
|
||||
<div
|
||||
v-if="conflicts.length"
|
||||
class="q-mt-xs"
|
||||
>
|
||||
<div class="text-caption">Conflits :</div>
|
||||
<ul class="q-pl-md q-mt-xs">
|
||||
<li v-for="(c, i) in conflicts" :key="i">
|
||||
<li
|
||||
v-for="(c, i) in conflicts"
|
||||
:key="i"
|
||||
>
|
||||
{{ c.start_time }}–{{ c.end_time }} ({{ c.type }})
|
||||
</li>
|
||||
</ul>
|
||||
|
|
@ -227,20 +259,23 @@ watch(
|
|||
/>
|
||||
<q-btn
|
||||
v-if="props.mode === 'delete'"
|
||||
outline color="negative"
|
||||
outline
|
||||
color="negative"
|
||||
icon="cancel"
|
||||
:label="$t('timesheet.delete_button')"
|
||||
:loading="isSubmitting"
|
||||
:disable="!canSubmit"
|
||||
@click="onSubmit"
|
||||
/>
|
||||
<q-btn v-else
|
||||
<q-btn
|
||||
v-else
|
||||
color="primary"
|
||||
icon="save_alt"
|
||||
:label="$t('timesheet.save_button')"
|
||||
:loading="isSubmitting"
|
||||
:disable="!canSubmit"
|
||||
@click="onSubmit"/>
|
||||
@click="onSubmit"
|
||||
/>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import type {
|
|||
ExpensePayload,
|
||||
PayPeriodExpenses,
|
||||
TimesheetExpense,
|
||||
UpsertExpenseResult,
|
||||
UpsertExpensesBody,
|
||||
UpsertExpensesResponse
|
||||
} from "../../types/expense.interfaces";
|
||||
|
|
@ -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{
|
||||
date: string;
|
||||
type: ExpenseType;
|
||||
|
|
@ -38,6 +39,16 @@ export interface ExpensePayload{
|
|||
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 {
|
||||
expenses: ExpensePayload[];
|
||||
}
|
||||
|
|
@ -45,3 +56,8 @@ export interface UpsertExpensesBody {
|
|||
export interface UpsertExpensesResponse {
|
||||
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 {
|
||||
date: string;
|
||||
|
|
|
|||
|
|
@ -25,6 +25,3 @@ export type ShiftLegendItem = {
|
|||
label_key: string;
|
||||
text_color?: string;
|
||||
};
|
||||
|
||||
export type UpsertAction = 'created' | 'updated' | 'deleted';
|
||||
|
||||
|
|
|
|||
|
|
@ -4,3 +4,5 @@ export type PayPeriodLabel = {
|
|||
start_date: string;
|
||||
end_date: string;
|
||||
};
|
||||
|
||||
export type UpsertAction = 'created' | 'updated' | 'deleted';
|
||||
|
|
@ -47,7 +47,7 @@ export const makeExpenseRules = (t: (key: string) => string, max_comment_char: n
|
|||
|
||||
const amountRequired = (val: unknown) => (isPresent(val)) || t('timesheet.expense.errors.amount_required_for_type');
|
||||
|
||||
const mileageRequired = (val: unknown) => (isPresent(val)) || t('timesheet.expense.erros.mileage_required_for_type');
|
||||
const mileageRequired = (val: unknown) => (isPresent(val)) || t('timesheet.expense.errors.mileage_required_for_type');
|
||||
|
||||
const commentRequired = (val: unknown) => (String(val ?? '').trim().length > 0) || t('timesheet.expense.errors.comment_required');
|
||||
|
||||
|
|
|
|||
|
|
@ -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