refactor(timesheet): update appearance, work on expense dialog, plugging to backend.

This commit is contained in:
Nicolas Drolet 2025-11-11 12:45:38 -05:00
parent 62385461d5
commit 1274a1b65b
10 changed files with 78 additions and 109 deletions

View File

@ -5,6 +5,7 @@
const NEXT = 1; const NEXT = 1;
const PREVIOUS = -1; const PREVIOUS = -1;
const PAY_PERIOD_DATE_LIMIT = '2023/12/17';
const timesheet_store = useTimesheetStore(); const timesheet_store = useTimesheetStore();
@ -125,7 +126,7 @@
class="q-mt-xl" class="q-mt-xl"
today-btn today-btn
mask="YYYY-MM-DD" mask="YYYY-MM-DD"
:options="date => date > '2023/12/16'" :options="date => date >= PAY_PERIOD_DATE_LIMIT"
@update:model-value="onDateSelected" @update:model-value="onDateSelected"
/> />
</q-dialog> </q-dialog>

View File

@ -31,8 +31,8 @@
}; };
const cancelUpdateMode = () => { const cancelUpdateMode = () => {
expenses_store.current_expense = new Expense; expenses_store.current_expense = new Expense(date.formatDate(new Date(), 'YYYY-MM-DD'));
expenses_store.initial_expense = new Expense; expenses_store.initial_expense = new Expense(date.formatDate(new Date(), 'YYYY-MM-DD'));
expenses_store.mode = 'create'; expenses_store.mode = 'create';
}; };
@ -40,6 +40,12 @@
if (expenses_store.mode === 'create') await expenses_api.createExpenseByEmployeeEmail(employee_email ?? '', expenses_store.current_expense?.date ?? ''); if (expenses_store.mode === 'create') await expenses_api.createExpenseByEmployeeEmail(employee_email ?? '', expenses_store.current_expense?.date ?? '');
else await expenses_api.updateExpenseByEmployeeEmail(employee_email ?? '', expenses_store.current_expense?.date ?? ''); else await expenses_api.updateExpenseByEmployeeEmail(employee_email ?? '', expenses_store.current_expense?.date ?? '');
}; };
const getExpenseCalendarRange = (current_date: string) => {
const period = timesheet_store.pay_period;
if (period !== undefined) return current_date >= period.period_start && current_date <= period.period_end;
return false;
}
</script> </script>
<template> <template>
@ -83,6 +89,7 @@
v-model="expenses_store.current_expense.date" v-model="expenses_store.current_expense.date"
mask="YYYY-MM-DD" mask="YYYY-MM-DD"
event-color="accent" event-color="accent"
:options="getExpenseCalendarRange"
@update:model-value="is_navigator_open = false" @update:model-value="is_navigator_open = false"
/> />
</q-dialog> </q-dialog>

View File

@ -2,7 +2,8 @@
setup setup
lang="ts" lang="ts"
> >
import { computed, inject, ref } from 'vue'; import { date } from 'quasar';
import { computed, ref } from 'vue';
import { unwrapAndClone } from 'src/utils/unwrap-and-clone'; import { unwrapAndClone } from 'src/utils/unwrap-and-clone';
import { deepEqual } from 'src/utils/deep-equal'; import { deepEqual } from 'src/utils/deep-equal';
import { useExpensesApi } from 'src/modules/timesheets/composables/use-expense-api'; import { useExpensesApi } from 'src/modules/timesheets/composables/use-expense-api';
@ -22,7 +23,6 @@
const expenses_store = useExpensesStore(); const expenses_store = useExpensesStore();
const auth_store = useAuthStore(); const auth_store = useAuthStore();
const expenses_api = useExpensesApi(); const expenses_api = useExpensesApi();
const employee_email = inject<string>('employeeEmail') ?? '';
const refresh_key = ref(1); const refresh_key = ref(1);
const background_class = computed(() => deepEqual(expense, expenses_store.current_expense) ? '' : ''); const background_class = computed(() => deepEqual(expense, expenses_store.current_expense) ? '' : '');
@ -31,10 +31,7 @@
const is_authorized_to_approve = computed(() => CAN_APPROVE_PAY_PERIODS.includes(auth_store.user?.role ?? 'GUEST')) const is_authorized_to_approve = computed(() => CAN_APPROVE_PAY_PERIODS.includes(auth_store.user?.role ?? 'GUEST'))
const requestExpenseDeletion = async () => { const requestExpenseDeletion = async () => {
// expenses_store.mode = 'delete'; await expenses_api.deleteExpenseById(expense.id);
expenses_store.initial_expense = expense;
expenses_store.current_expense = new Expense;
await expenses_api.deleteExpenseByEmployeeEmail(employee_email, expenses_store.initial_expense.date);
} }
const onExpenseClicked = () => { const onExpenseClicked = () => {
@ -47,7 +44,7 @@
const onUpdateClicked = () => { const onUpdateClicked = () => {
if (deepEqual(expense, expenses_store.current_expense)) { if (deepEqual(expense, expenses_store.current_expense)) {
expenses_store.mode = 'create'; expenses_store.mode = 'create';
expenses_store.current_expense = new Expense; expenses_store.current_expense = new Expense(date.formatDate(new Date(), 'YYYY-MM-DD'));
return; return;
} }
@ -138,7 +135,10 @@
v-if="!horizontal" v-if="!horizontal"
top top
> >
<q-item-label lines="1" class="text-weight-medium text-uppercase"> <q-item-label
lines="1"
class="text-weight-medium text-uppercase"
>
{{ $t('timesheet.expense.employee_comment') }} {{ $t('timesheet.expense.employee_comment') }}
</q-item-label> </q-item-label>
<q-item-label <q-item-label
@ -155,7 +155,10 @@
v-if="is_authorized_to_approve" v-if="is_authorized_to_approve"
top top
> >
<q-item-label lines="1" class="text-weight-medium text-uppercase"> <q-item-label
lines="1"
class="text-weight-medium text-uppercase"
>
{{ $t('timesheet.expense.supervisor_comment') }} {{ $t('timesheet.expense.supervisor_comment') }}
</q-item-label> </q-item-label>
<q-item-label <q-item-label
@ -170,9 +173,10 @@
<q-item-section :side="$q.screen.gt.sm"> <q-item-section :side="$q.screen.gt.sm">
<q-btn <q-btn
flat flat
color="accent"
icon="edit"
size="lg" size="lg"
icon="edit"
color="accent"
:disable="expense.is_approved"
class="q-pa-none z-top" class="q-pa-none z-top"
:class="expense.is_approved ? 'invisible no-pointer' : ''" :class="expense.is_approved ? 'invisible no-pointer' : ''"
@click.stop="onUpdateClicked" @click.stop="onUpdateClicked"
@ -182,10 +186,11 @@
<q-item-section :side="$q.screen.gt.sm"> <q-item-section :side="$q.screen.gt.sm">
<q-btn <q-btn
flat flat
:color="expense.is_approved ? 'white' : 'negative'"
:icon="expense.is_approved ? 'verified' : 'close'"
size="lg" size="lg"
:icon="expense.is_approved ? 'verified' : 'close'"
:color="expense.is_approved ? 'white' : 'negative'"
class="q-pa-none z-top" class="q-pa-none z-top"
:class="expense.is_approved ? 'no-pointer' : ''"
@click.stop="requestExpenseDeletion" @click.stop="requestExpenseDeletion"
/> />
</q-item-section> </q-item-section>

View File

@ -2,14 +2,23 @@
setup setup
lang="ts" lang="ts"
> >
import { useExpensesStore } from 'src/stores/expense-store'; import { computed } from 'vue';
import { useTimesheetStore } from 'src/stores/timesheet-store';
import ExpenseDialogListItem from 'src/modules/timesheets/components/expense-dialog-list-item.vue'; import ExpenseDialogListItem from 'src/modules/timesheets/components/expense-dialog-list-item.vue';
const expenses_store = useExpensesStore(); const timesheet_store = useTimesheetStore();
const { horizontal = false } = defineProps<{ const { horizontal = false } = defineProps<{
horizontal?: boolean; horizontal?: boolean;
}>(); }>();
const expenses_list = computed(() => {
if (timesheet_store.timesheets !== undefined) {
return timesheet_store.timesheets.flatMap(week => week.days).flatMap(day => day.expenses);
}
return [];
})
</script> </script>
<template> <template>
@ -20,14 +29,14 @@
:class="horizontal ? 'row flex-center' : ''" :class="horizontal ? 'row flex-center' : ''"
> >
<q-item-label <q-item-label
v-if="expenses_store.pay_period_expenses?.length === 0" v-if="expenses_list.length > 0"
class="text-italic q-px-sm" class="text-italic q-px-sm"
> >
{{ $t('timesheet.expense.empty_list') }} {{ $t('timesheet.expense.empty_list') }}
</q-item-label> </q-item-label>
<ExpenseDialogListItem <ExpenseDialogListItem
v-for="(expense, index) in expenses_store.pay_period_expenses" v-for="(expense, index) in expenses_list"
:key="index" :key="index"
v-model="expense.is_approved" v-model="expense.is_approved"
:index="index" :index="index"

View File

@ -84,7 +84,6 @@
<template> <template>
<q-slide-item <q-slide-item
right-color="negative" right-color="negative"
class="q-my-xs rounded-5 bg-transparent" class="q-my-xs rounded-5 bg-transparent"
@right="details => slideDeleteShift(details.reset)" @right="details => slideDeleteShift(details.reset)"

View File

@ -16,13 +16,13 @@ export const useExpensesApi = () => {
// await expenses_store.upsertOrDeleteExpensesByEmployeeEmail(employee_email, date, upsert_expense); // await expenses_store.upsertOrDeleteExpensesByEmployeeEmail(employee_email, date, upsert_expense);
}; };
const deleteExpenseByEmployeeEmail = async (employee_email: string, date: string): Promise<void> => { const deleteExpenseById = async (expense_id: number): Promise<void> => {
// await expenses_store.upsertOrDeleteExpensesByEmployeeEmail(employee_email, date, upsert_expense); await expenses_store.deleteExpenseById(expense_id);
}; };
return { return {
createExpenseByEmployeeEmail, createExpenseByEmployeeEmail,
updateExpenseByEmployeeEmail, updateExpenseByEmployeeEmail,
deleteExpenseByEmployeeEmail, deleteExpenseById,
}; };
}; };

View File

@ -14,35 +14,12 @@ export class Expense {
supervisor_comment?: string; supervisor_comment?: string;
is_approved: boolean; is_approved: boolean;
constructor() { constructor(date: string) {
this.id = -1; this.id = -1;
this.date = ''; this.date = date;
this.type = 'EXPENSES'; this.type = 'EXPENSES';
this.amount = 0; this.amount = 0;
this.comment = ''; this.comment = '';
this.is_approved = false; this.is_approved = false;
}; };
}; };
export const test_expenses: Expense[] = [
{
id: 201,
date: '2025-01-06',
type: 'EXPENSES',
amount: 15.5,
comment: 'Lunch receipt',
is_approved: false,
},
{
id: 202,
date: '2025-01-07',
type: 'MILEAGE',
amount: 0,
mileage: 32.4,
comment: 'Travel to client site',
is_approved: true,
},
];

View File

@ -1,13 +1,19 @@
import { api } from "src/boot/axios"; import { api } from "src/boot/axios";
import type { Expense } from "src/modules/timesheets/models/expense.models";
export const ExpenseService = { export const ExpenseService = {
getExpensesByTimesheetId: async (timesheet_id: number) => { createExpense: async (expense: Expense) => {
const response = await api.get(`timesheet/${timesheet_id}`); const response = await api.post('expense/create', expense);
return response.data; return response.data;
}, },
upsertOrDeleteExpenseById: async (expense_id: number) => { updateExpenseById: async (expense: Expense) => {
const response = await api.post(`epxense/${expense_id}`); const response = await api.patch(`expense/update`, expense);
return response.data; return response.data;
}, },
deleteExpenseById: async (expense_id: number): Promise<{ok: boolean, id: number, error?: unknown}> => {
const response = await api.delete(`expense/delete/${expense_id}`);
return response.data;
}
}; };

View File

@ -1,8 +1,8 @@
import { ref } from "vue"; import { ref } from "vue";
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { ExpensesApiError, type GenericApiError } from "src/modules/timesheets/models/expense-validation.models"; import { Expense } from "src/modules/timesheets/models/expense.models";
import { test_expenses, Expense } from "src/modules/timesheets/models/expense.models";
import { ExpenseService } from "src/modules/timesheets/services/expense-service"; import { ExpenseService } from "src/modules/timesheets/services/expense-service";
import { date } from "quasar";
@ -10,82 +10,47 @@ export const useExpensesStore = defineStore('expenses', () => {
const is_open = ref(false); const is_open = ref(false);
const is_loading = ref(false); const is_loading = ref(false);
const mode = ref<'create' | 'update' | 'delete'>('create'); const mode = ref<'create' | 'update' | 'delete'>('create');
const pay_period_expenses = ref<Expense[]>(test_expenses); const current_expense = ref<Expense>(new Expense(date.formatDate(new Date(), 'YYYY-MM-DD')));
const current_expense = ref<Expense>(new Expense); const initial_expense = ref<Expense>(new Expense(date.formatDate(new Date(), 'YYYY-MM-DD')));
const initial_expense = ref<Expense>(new Expense);
const error = ref<string | null>(null);
// const setErrorFrom = (err: unknown) => {
// const e = err as any;
// error.value = e?.message || 'Unknown error';
// };
const open = (): void => { const open = (): void => {
is_open.value = true; is_open.value = true;
error.value = null; current_expense.value = new Expense(date.formatDate(new Date(), 'YYYY-MM-DD'));
current_expense.value = new Expense; initial_expense.value = new Expense(date.formatDate(new Date(), 'YYYY-MM-DD'));
initial_expense.value = new Expense;
mode.value = 'create'; mode.value = 'create';
} }
const close = () => { const close = () => {
error.value = null;
is_open.value = false; is_open.value = false;
}; };
const getPayPeriodExpensesByTimesheetId = async (timesheet_id: number): Promise<void> => { const upsertExpensesById = async (expense_id: number, expense: Expense): Promise<void> => {
is_loading.value = true;
error.value = null;
try { try {
const expenses = await ExpenseService.getExpensesByTimesheetId(timesheet_id); if (expense_id < 0) {
pay_period_expenses.value = expenses; const data = await ExpenseService.createExpense(expense);
} catch (err: unknown) { return data;
if (typeof err === 'object') {
const error = err as GenericApiError;
const status_code: number = error.status_code ?? 500;
// const data = error.context ?? '';
// error.value = data.message || data.error || err.message;
throw new ExpensesApiError({
status_code,
// error_code: data.error_code,
// message: data.message || data.error || err.message,
// context: data.context,
});
} }
} finally {
is_loading.value = false;
}
};
const upsertOrDeleteExpensesById = async (expense_id: number): Promise<void> => {
is_loading.value = true;
error.value = null;
try {
await ExpenseService.upsertOrDeleteExpenseById(expense_id);
// TODO: Save response data into proper ref // TODO: Save response data into proper ref
} catch (err) { } catch (err) {
// setErrorFrom(err); // setErrorFrom(err);
console.error(err); console.error(err);
} finally {
is_loading.value = false;
} }
}; };
const deleteExpenseById = async (expense_id: number): Promise<boolean> => {
const data = await ExpenseService.deleteExpenseById(expense_id);
return data.ok;
}
return { return {
is_open, is_open,
is_loading, is_loading,
mode, mode,
pay_period_expenses,
current_expense, current_expense,
initial_expense, initial_expense,
error,
open, open,
getPayPeriodExpensesByTimesheetId, upsertExpensesById,
upsertOrDeleteExpensesById, deleteExpenseById,
close, close,
}; };
}); });

View File

@ -1,13 +1,13 @@
import { ref } from 'vue'; import { ref } from 'vue';
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { useAuthStore } from 'src/stores/auth-store'; import { useAuthStore } from 'src/stores/auth-store';
import { unwrapAndClone } from 'src/utils/unwrap-and-clone';
import { timesheetApprovalService } from 'src/modules/timesheet-approval/services/timesheet-approval-service'; import { timesheetApprovalService } from 'src/modules/timesheet-approval/services/timesheet-approval-service';
import { timesheetService } from 'src/modules/timesheets/services/timesheet-service'; import { timesheetService } from 'src/modules/timesheets/services/timesheet-service';
import type { TimesheetOverview } from "src/modules/timesheet-approval/models/timesheet-overview.models"; import type { TimesheetOverview } from "src/modules/timesheet-approval/models/timesheet-overview.models";
import type { PayPeriod } from 'src/modules/shared/models/pay-period.models'; import type { PayPeriod } from 'src/modules/shared/models/pay-period.models';
import type { Timesheet } from 'src/modules/timesheets/models/timesheet.models'; import type { Timesheet } from 'src/modules/timesheets/models/timesheet.models';
import type { TimesheetApprovalCSVReportFilters } from 'src/modules/timesheet-approval/models/timesheet-approval-csv-report.models'; import type { TimesheetApprovalCSVReportFilters } from 'src/modules/timesheet-approval/models/timesheet-approval-csv-report.models';
import { unwrapAndClone } from 'src/utils/unwrap-and-clone';
export const useTimesheetStore = defineStore('timesheet', () => { export const useTimesheetStore = defineStore('timesheet', () => {