Merge pull request 'dev/nicolas/staging-prep' (#69) from dev/nicolas/staging-prep into main

Reviewed-on: Targo/targo_frontend#69
This commit is contained in:
Nicolas 2026-01-27 15:07:18 -05:00
commit 79c67b8637
6 changed files with 139 additions and 95 deletions

View File

@ -53,7 +53,11 @@
<!-- list of shifts -->
<div class="col-auto column no-wrap">
<TimesheetWrapper mode="approval" class="col-auto"/>
<TimesheetWrapper
mode="approval"
:employee-email="timesheet_store.current_pay_period_overview?.email"
class="col-auto"
/>
</div>
</div>
</q-dialog>

View File

@ -4,7 +4,7 @@
>
import { date } from 'quasar';
import { useI18n } from 'vue-i18n';
import { computed, onMounted, ref } from 'vue';
import { computed, inject, onMounted, ref } from 'vue';
import { useUiStore } from 'src/stores/ui-store';
import { useExpensesStore } from 'src/stores/expense-store';
import { useTimesheetStore } from 'src/stores/timesheet-store';
@ -12,11 +12,16 @@
import { getExpenseIcon, useExpenseRules } from 'src/modules/timesheets/utils/expense.util';
import { Expense, type ExpenseType, TYPES_WITH_AMOUNT_ONLY } from 'src/modules/timesheets/models/expense.models';
// ================= state ======================
interface ExpenseOption {
label: string;
value: ExpenseType;
icon: string;
}
const expense = defineModel<Expense>({ default: new Expense(new Date().toISOString().slice(0, 10)) })
const COMMENT_MAX_LENGTH = 280;
const { t } = useI18n();
const ui_store = useUiStore();
@ -24,16 +29,9 @@
const expenses_store = useExpensesStore();
const expenses_api = useExpensesApi();
const files = defineModel<File[] | null>('files');
const is_navigator_open = ref(false);
const COMMENT_MAX_LENGTH = 280;
const rules = useExpenseRules(t);
const period_start_date = computed(() => timesheet_store.pay_period?.period_start.replaceAll('-', '/') ?? '');
const period_end_date = computed(() => timesheet_store.pay_period?.period_end.replaceAll('-', '/') ?? '');
const expense = defineModel<Expense>({ default: new Expense(new Date().toISOString().slice(0, 10)) })
const expense_options: ExpenseOption[] = [
{ label: t('timesheet.expense.types.PER_DIEM'), value: 'PER_DIEM', icon: getExpenseIcon('PER_DIEM') },
@ -41,8 +39,15 @@
{ label: t('timesheet.expense.types.MILEAGE'), value: 'MILEAGE', icon: getExpenseIcon('MILEAGE') },
{ label: t('timesheet.expense.types.ON_CALL'), value: 'ON_CALL', icon: getExpenseIcon('ON_CALL') },
]
const expense_selected = ref<ExpenseOption | undefined>();
const employeeEmail = inject<string>('employeeEmail');
// ================== computed ===================
const period_start_date = computed(() => timesheet_store.pay_period?.period_start.replaceAll('-', '/') ?? '');
const period_end_date = computed(() => timesheet_store.pay_period?.period_end.replaceAll('-', '/') ?? '');
// ==================== method =======================
const openDatePicker = () => {
is_navigator_open.value = true;
@ -57,11 +62,8 @@
}
const requestExpenseCreationOrUpdate = async () => {
if (expenses_store.mode === 'update')
await expenses_api.upsertExpense(expenses_store.current_expense, timesheet_store.current_pay_period_overview?.email);
else
await expenses_api.upsertExpense(expenses_store.current_expense);
await expenses_api.upsertExpense(expenses_store.current_expense, employeeEmail);
expenses_store.is_showing_create_form = true;
expenses_store.mode = 'create';
expenses_store.current_expense = new Expense(date.formatDate(new Date(), 'YYYY-MM-DD'));

View File

@ -2,23 +2,31 @@
setup
lang="ts"
>
import { computed } from 'vue';
import { computed, inject } from 'vue';
import { useTimesheetStore } from 'src/stores/timesheet-store';
import ExpenseDialogListItem from 'src/modules/timesheets/components/expense-dialog-list-item.vue';
import ExpenseDialogListItemMobile from 'src/modules/timesheets/components/mobile/expense-dialog-list-item-mobile.vue';
// ================== state =======================
const timesheet_store = useTimesheetStore();
const { mode = 'normal' } = defineProps<{
mode?: 'approval' | 'normal';
}>();
// ========================== computed ==========================
const expenses_list = computed(() => {
if (timesheet_store.timesheets !== undefined) {
return timesheet_store.timesheets.flatMap(week => week.days).flatMap(day => day.expenses);
}
return [];
})
// ==================== methods ========================
inject( 'employeeEmail', mode === 'approval' ? timesheet_store.current_pay_period_overview?.email : undefined);
</script>
<template>

View File

@ -3,24 +3,16 @@
lang="ts"
>
import { useI18n } from 'vue-i18n';
import { onMounted, ref } from 'vue';
import { QSelect, QInput, useQuasar } from 'quasar';
import { computed, onMounted, ref } from 'vue';
import { QSelect, QInput, useQuasar, QSelectProps } from 'quasar';
import { useUiStore } from 'src/stores/ui-store';
import { SHIFT_OPTIONS } from 'src/modules/timesheets/utils/shift.util';
import type { Shift } from 'src/modules/timesheets/models/shift.models';
const q = useQuasar();
const { t } = useI18n();
const ui_store = useUiStore();
// ================== State ==================
const COMMENT_LENGTH_MAX = 280;
const shift = defineModel<Shift>('shift', { required: true });
const shift_type_selected = ref(SHIFT_OPTIONS.find(option => option.value == shift.value.type));
const select_ref = ref<QSelect | null>(null);
const error_message = ref<string | undefined>();
const is_showing_delete_confirm = ref(false);
const { errorMessage = undefined, isTimesheetApproved = false, holiday = false } = defineProps<{
dense?: boolean;
isTimesheetApproved?: boolean;
@ -28,7 +20,25 @@
holiday?: boolean | undefined;
}>();
const time_input_props = {
const emit = defineEmits<{
'requestDelete': [void];
'onTimeFieldBlur': [void];
}>();
const shift = defineModel<Shift>('shift', { required: true });
const q = useQuasar();
const { t } = useI18n();
const ui_store = useUiStore();
const shiftTypeSelected = ref(SHIFT_OPTIONS.find(option => option.value == shift.value.type));
const selectRef = ref<QSelect | null>(null);
const shiftErrorMessage = ref<string | undefined>();
const is_showing_delete_confirm = ref(false);
// ================== Computed ==================
const timeInputProps = computed(() => ({
dense: true,
borderless: shift.value.is_approved && isTimesheetApproved,
readonly: shift.value.is_approved && isTimesheetApproved,
@ -38,32 +48,46 @@
noErrorIcon: true,
hideBottomSpace: true,
error: shift.value.has_error,
errorMessage: errorMessage ? t(errorMessage) : (error_message.value ? t(error_message.value) : undefined),
errorMessage: errorMessage ? t(errorMessage) : (shiftErrorMessage.value ? t(shiftErrorMessage.value) : undefined),
labelColor: shift.value.is_approved ? 'white' : (holiday ? 'purple-5' : 'accent'),
class: `col rounded-5 bg-dark q-mx-xs ${shift.value.id === -2 ? 'bg-negative' : ''} ${shift.value.is_approved || isTimesheetApproved ? 'cursor-not-allowed inset-shadow' : ''}`,
inputClass: `text-weight-medium ${shift.value.id === -2 ? 'text-white ' : ' '} ${shift.value.is_approved ? 'text-white cursor-not-allowed q-px-sm' : ''}`,
style: shift.value.is_approved ? (holiday ? 'background-color: #7b1fa2 !important' : 'background-color: #0a7d32 !important;') : '',
inputStyle: "font-size: 1.2em;"
}
}));
const emit = defineEmits<{
'requestDelete': [void];
'onTimeFieldBlur': [void];
}>();
const shiftTypeSelectProps = computed<Partial<QSelectProps>>(() => ({
standout: q.dark.isActive ? 'bg-blue-grey-3' : 'bg-blue-grey-9',
dense: true,
borderless: shift.value.is_approved && isTimesheetApproved,
readonly: shift.value.is_approved && isTimesheetApproved,
optionsDense: !ui_store.is_mobile_mode,
hideDropdownIcon: true,
menuOffset: [0, 10],
menuAnchor: "bottom middle",
menuSelf: "top middle",
options: SHIFT_OPTIONS,
class: `col rounded-5 q-mx-xs bg-dark ${!shift.value.is_approved && !isTimesheetApproved ? '' : 'inset-shadow'}`,
popupContentClass: "text-uppercase text-weight-bold text-center rounded-5",
style: shift.value.is_approved ? (holiday ? 'background-color: #7b1fa2 !important' : 'background-color: #0a7d32 !important;') : '',
popupContentStyle: "border: 2px solid var(--q-accent)",
}));
// ================== Methods ==================
const onTimeFieldBlur = (time_string: string) => {
if (time_string.length < 1 || !time_string) {
shift.value.has_error = true;
error_message.value = 'timesheet.errors.SHIFT_TIME_REQUIRED'
shiftErrorMessage.value = 'timesheet.errors.SHIFT_TIME_REQUIRED'
} else {
shift.value.has_error = false;
error_message.value = undefined;
shiftErrorMessage.value = undefined;
emit('onTimeFieldBlur');
}
}
const onBlurShiftTypeSelect = () => {
if (shift_type_selected.value === undefined) {
if (shiftTypeSelected.value === undefined) {
shift.value.type = 'REGULAR';
shift.value.id = 0;
emit('requestDelete');
@ -81,18 +105,22 @@
return 'negative';
};
const toggleIsShowingDeleteConfirm = (state: boolean) => {
is_showing_delete_confirm.value = state;
}
onMounted(() => {
if (ui_store.focus_next_component) {
select_ref.value?.focus();
select_ref.value?.showPopup();
shift_type_selected.value = undefined;
selectRef.value?.focus();
selectRef.value?.showPopup();
shiftTypeSelected.value = undefined;
ui_store.focus_next_component = false;
}
});
</script>
<template>
<div :class="ui_store.is_mobile_mode ? 'column' : 'row'">
<div class="row">
<!-- delete shift confirmation dialog -->
<q-dialog
v-model="is_showing_delete_confirm"
@ -110,7 +138,7 @@
color="negative"
:label="$t('shared.misc.no')"
class="col"
@click="is_showing_delete_confirm = false"
@click="toggleIsShowingDeleteConfirm(false)"
/>
<q-btn
@ -130,23 +158,9 @@
>
<!-- shift type -->
<q-select
ref="select_ref"
v-model="shift_type_selected"
:standout="$q.dark.isActive ? 'bg-blue-grey-3' : 'bg-blue-grey-9'"
dense
:borderless="(shift.is_approved && isTimesheetApproved)"
:readonly="(shift.is_approved && isTimesheetApproved)"
:options-dense="!ui_store.is_mobile_mode"
hide-dropdown-icon
:menu-offset="[0, 10]"
menu-anchor="bottom middle"
menu-self="top middle"
:options="SHIFT_OPTIONS"
class="col rounded-5 q-mx-xs bg-dark"
:class="!shift.is_approved && !isTimesheetApproved ? '' : 'inset-shadow'"
popup-content-class="text-uppercase text-weight-bold text-center rounded-5"
:style="shift.is_approved ? (holiday ? 'background-color: #7b1fa2 !important' : 'background-color: #0a7d32 !important;') : ''"
popup-content-style="border: 2px solid var(--q-accent)"
ref="selectRef"
v-model="shiftTypeSelected"
v-bind="shiftTypeSelectProps"
@blur="onBlurShiftTypeSelect"
@update:model-value="option => shift.type = option.value"
>
@ -173,6 +187,22 @@
</div>
</template>
<template #option="scope">
<q-item
clickable
v-bind="scope.itemProps"
>
<q-item-section avatar>
<q-icon :name="scope.opt.icon" />
</q-item-section>
<q-item-section class="text-left">
{{ $t(scope.label) }}
</q-item-section>
</q-item>
</template>
<!-- work-from-home toggle -->
<template #after>
<q-icon
v-if="shift.is_approved"
@ -214,21 +244,6 @@
</q-tooltip>
</q-toggle>
</template>
<template #option="scope">
<q-item
clickable
v-bind="scope.itemProps"
>
<q-item-section avatar>
<q-icon :name="scope.opt.icon" />
</q-item-section>
<q-item-section class="text-left">
{{ $t(scope.label) }}
</q-item-section>
</q-item>
</template>
</q-select>
</div>
@ -237,7 +252,7 @@
<q-input
ref="start_time"
v-model="shift.start_time"
v-bind="time_input_props"
v-bind="timeInputProps"
type="time"
@blur="onTimeFieldBlur(shift.start_time)"
>
@ -254,7 +269,7 @@
<q-input
ref="end_time"
v-model="shift.end_time"
v-bind="time_input_props"
v-bind="timeInputProps"
type="time"
@blur="onTimeFieldBlur(shift.end_time)"
>
@ -267,12 +282,11 @@
</template>
</q-input>
<!-- comment and delete buttons -->
<div
class="row full-height"
:class="ui_store.is_mobile_mode ? 'col-12' : 'col-auto flex-center'"
>
<!-- desktop comment button -->
<!-- comment button -->
<q-btn
v-if="!ui_store.is_mobile_mode"
push
@ -291,6 +305,7 @@
class="text-blue-9 text-weight-bolder"
>!!</q-badge>
<!-- popup to edit comment, with visual indicator of character limit -->
<q-popup-edit
v-model="shift.comment"
v-slot="scope"
@ -339,6 +354,7 @@
</q-popup-edit>
</q-btn>
<!-- delete button -->
<q-btn
v-if="!shift.is_approved"
flat
@ -350,14 +366,15 @@
class="col"
size="1.2em"
:class="shift.is_approved ? 'invisible' : ''"
@click="is_showing_delete_confirm = true"
>
</q-btn>
@click="toggleIsShowingDeleteConfirm(true)"
/>
</div>
</div>
</div>
</template>
<!-- styling the error message component to ressemble a red tab that
drops down, rather than the standard floating red text only -->
<style scoped>
:deep(.q-field--error) {
background-color: var(--q-negative) !important;

View File

@ -5,7 +5,7 @@
import ShiftListDayRow from 'src/modules/timesheets/components/shift-list-day-row.vue';
import ShiftListDayRowMobile from 'src/modules/timesheets/components/mobile/shift-list-day-row-mobile.vue';
import { ref } from 'vue';
import { inject, ref } from 'vue';
import { useTimesheetStore } from 'src/stores/timesheet-store';
import { useShiftApi } from 'src/modules/timesheets/composables/use-shift-api';
import { useTimesheetApi } from 'src/modules/timesheets/composables/use-timesheet-api';
@ -13,27 +13,30 @@
import type { TimesheetDay } from 'src/modules/timesheets/models/timesheet.models';
import { isShiftOverlap } from 'src/modules/timesheets/utils/shift.util';
const shift_api = useShiftApi();
const timesheet_api = useTimesheetApi();
const timesheet_store = useTimesheetStore();
const shift_error_message = ref<string | undefined>();
// ================== State ==================
const { day, dense = false, approved = false, holiday = false, employeeEmail } = defineProps<{
const { day, dense = false, approved = false, holiday = false } = defineProps<{
timesheetId: number;
weekDayIndex: number;
day: TimesheetDay;
dense?: boolean;
approved?: boolean;
holiday?: boolean;
employeeEmail?: string;
}>();
const preset_mouseover = ref(false);
const emit = defineEmits<{
'deleteUnsavedShift': [void];
}>();
const shift_api = useShiftApi();
const timesheet_api = useTimesheetApi();
const timesheet_store = useTimesheetStore();
const preset_mouseover = ref(false);
const shift_error_message = ref<string | undefined>();
const employeeEmail = inject<string>('employeeEmail');
// ================== Methods ==================
const deleteCurrentShift = async (shift: Shift) => {
if (shift.id <= 0) {
shift.id = 0;

View File

@ -12,19 +12,29 @@
import ShiftListWeeklyOverview from 'src/modules/timesheets/components/shift-list-weekly-overview.vue';
import ShiftListWeeklyOverviewMobile from 'src/modules/timesheets/components/mobile/shift-list-weekly-overview-mobile.vue';
import { date, Notify } from 'quasar';
import { useI18n } from 'vue-i18n';
import { computed, onMounted } from 'vue';
import { computed, onMounted, provide } from 'vue';
import { useShiftApi } from 'src/modules/timesheets/composables/use-shift-api';
import { useTimesheetApi } from 'src/modules/timesheets/composables/use-timesheet-api';
import { useExpensesStore } from 'src/stores/expense-store';
import { useTimesheetStore } from 'src/stores/timesheet-store';
import { date, Notify } from 'quasar';
// ================= state ====================
const { mode = 'normal', employeeEmail } = defineProps<{
mode?: 'approval' | 'normal';
employeeEmail?: string | undefined;
}>();
const { t } = useI18n();
const expenses_store = useExpensesStore();
const timesheet_store = useTimesheetStore();
const timesheet_api = useTimesheetApi();
const shift_api = useShiftApi();
// ================== computed ====================
const has_shift_errors = computed(() => timesheet_store.all_current_shifts.filter(shift => shift.has_error === true).length > 0);
const is_timesheets_approved = computed(() => timesheet_store.timesheets.every(timesheet => timesheet.is_approved))
@ -44,9 +54,9 @@
0 //initial value
));
const { mode = 'normal' } = defineProps<{
mode?: 'approval' | 'normal';
}>();
// =================== methods ==========================
provide('employeeEmail', employeeEmail);
const onClickSaveTimesheets = async () => {
if (mode === 'normal') {