Merge pull request 'dev/nicolas/timesheet-gui-refactor' (#25) from dev/nicolas/timesheet-gui-refactor into main

Reviewed-on: Targo/targo_frontend#25
This commit is contained in:
Nicolas 2025-11-18 14:51:56 -05:00
commit 1dc001af49
30 changed files with 1615 additions and 1036 deletions

View File

@ -105,7 +105,7 @@ export default defineConfig((ctx) => {
notify: { notify: {
color: 'primary', color: 'primary',
}, },
dark: false, dark: 'auto',
}, },
// iconSet: 'material-icons', // Quasar icon set // iconSet: 'material-icons', // Quasar icon set

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 MiB

After

Width:  |  Height:  |  Size: 242 KiB

View File

@ -15,15 +15,16 @@
$primary : #30303A; $primary : #30303A;
$secondary : #DAE0E7; $secondary : #DAE0E7;
$accent : #0c9a3b; $accent : #0c9a3b;
$accent2 : #0a7d32;
$dark-shadow-color : #173625; $dark-shadow-color : #173625;
$elevation-dark-umbra : rgba($dark-shadow-color, 1); $elevation-dark-umbra : rgba($dark-shadow-color, 1);
$elevation-dark-penumbra : rgba($dark-shadow-color, 0.5); $elevation-dark-penumbra : rgba($dark-shadow-color, 0.75);
$elevation-dark-ambient : rgba($dark-shadow-color, 0.3); $elevation-dark-ambient : rgba($dark-shadow-color, 0.53);
$dark-shadow-2 : 2px 3px $elevation-dark-umbra, 2px 3px 6px $elevation-dark-penumbra, 2px 3px 14px $elevation-dark-ambient; $dark-shadow-2 : 2px 3px $elevation-dark-umbra, 2px 3px 6px $elevation-dark-penumbra, 2px 3px 14px $elevation-dark-ambient;
$layout-shadow-dark : 0 0 10px 5px rgba($dark-shadow-color, 0.5); $layout-shadow-dark : 0 0 5px 5px rgba($dark-shadow-color, 0.5);
$input-text-color : #455A64; $input-text-color : #455A64;
$input-autofill-color : #AAD5C4; $input-autofill-color : #AAD5C4;

View File

@ -155,14 +155,6 @@ export default {
VACATION: "Vacation", VACATION: "Vacation",
REMOTE: "Remote work", REMOTE: "Remote work",
}, },
errors: {
not_found: "Shift not found",
SHIFT_OVERLAP: "An overlaps occured between 2 or more shifts",
invalid: "Invalid shift`s entry",
unknown: "Unknown error",
comment_required: "A comment is required",
comment_too_long: "Your comment is too long",
},
fields: { fields: {
start: "Start (HH:mm)", start: "Start (HH:mm)",
end: "End (HH:mm)", end: "End (HH:mm)",
@ -177,16 +169,6 @@ export default {
empty_list: 'No registered expenses', empty_list: 'No registered expenses',
employee_comment: 'Comment', employee_comment: 'Comment',
supervisor_comment: 'Supervisor note', supervisor_comment: 'Supervisor note',
errors: {
date_required_or_invalid:"the date is missing or invalid",
comment_required:"A comment required",
comment_too_long:"Your comment is too long",
amount_must_be_positive:"the amount cannot be under 0$",
mileave_must_be_positive:"the mileage cannot be under 0",
amount_xor_mileage:"you cannot enter an amount and a mileage for the same expense",
mileage_required_for_type:"you need to enter a value for mileage when you enter an expense of that type",
amount_required_for_type:"you need to enter a value for amount when you enter an expense of that type",
},
hints: { hints: {
amount_or_mileage: "Either amount or mileage, not both", amount_or_mileage: "Either amount or mileage, not both",
comment_required: "A comment required", comment_required: "A comment required",
@ -205,6 +187,18 @@ export default {
ON_CALL: "on-call allowance", ON_CALL: "on-call allowance",
}, },
}, },
errors: {
INVALID_SHIFT_TIME: "In and Out shift times are reversed",
SHIFT_OVERLAP: "An overlaps occured between 2 or more shifts",
INVALID_SHIFT: "A shift contains missing or corrupted data",
SHIFT_NOT_FOUND: "Shift missing or deleted",
PAY_PERIOD_NOT_FOUND: "No pay period matching given dates",
EMPLOYEE_NOT_FOUND: "No employee matching current login details",
INVALID_TIMESHEET: "Timesheet data is missing or corrupted",
TIMESHEET_NOT_FOUND: "No timesheet found with provided data",
INVALID_EXPENSE: "An expense contains missing or corrupted data",
EXPENSE_NOT_FOUND: "No expense found with provided data",
},
}, },
timesheet_approvals: { timesheet_approvals: {

View File

@ -156,14 +156,6 @@ export default {
VACATION: "Vacance", VACATION: "Vacance",
REMOTE: "Télétravail", REMOTE: "Télétravail",
}, },
errors: {
not_found: "Aucun quart trouvé",
SHIFT_OVERLAP: "Il y a un chevauchement entre deux ou plusieurs quarts",
invalid: "Entrée du quart invalide",
unknown: "Erreur inconnue",
comment_required: "un commentaire est requis",
comment_too_long: "votre commentaire est trop long",
},
fields: { fields: {
start: "Début (HH:mm)", start: "Début (HH:mm)",
end: "Fin (HH:mm)", end: "Fin (HH:mm)",
@ -178,16 +170,6 @@ export default {
empty_list: 'Aucun dépense enregistrée', empty_list: 'Aucun dépense enregistrée',
employee_comment: 'Commentaire', employee_comment: 'Commentaire',
supervisor_comment: 'Note du Superviseur', supervisor_comment: 'Note du Superviseur',
errors: {
date_required_or_invalid:"La date est manquante ou invalide",
comment_required:"un commentaire est requis",
comment_too_long:"votre commentaire est trop long",
amount_must_be_positive:"le montant doit être suppérieur à 0$",
mileave_must_be_positive:"le kilométrage doit être suppérieur à 0",
amount_xor_mileage:"Vous ne pouvez pas saisir un montant et un kilométrage pour une même dépense",
mileage_required_for_type:"Vous devez entrer une valeur en kilométrage pour ce type de dépense",
amount_required_for_type:"Vous devez entrer une valeur en montant $ pour ce type de dépense",
},
hints: { hints: {
amount_or_mileage: "Soit dépense ou kilométrage, pas les deux", amount_or_mileage: "Soit dépense ou kilométrage, pas les deux",
comment_required: "un commentaire est requis", comment_required: "un commentaire est requis",
@ -206,6 +188,18 @@ export default {
ON_CALL: "Prime de garde", ON_CALL: "Prime de garde",
}, },
}, },
errors: {
INVALID_SHIFT_TIME: "Les heures d'entrée et de sortie sont inversées",
SHIFT_OVERLAP: "Il y a un chevauchement entre deux ou plusieurs quarts",
INVALID_SHIFT: "Un quart de travail contient des données manquantes ou corrompues",
SHIFT_NOT_FOUND: "Quart de travail manquant ou supprimé",
PAY_PERIOD_NOT_FOUND: "Aucune période de paie ne correspond aux dates fournies",
EMPLOYEE_NOT_FOUND: "Aucun employé ne correspond aux détails de votre connexion",
INVALID_TIMESHEET: "Une feuille de temps contient des données manquantes ou corrompues",
TIMESHEET_NOT_FOUND: "Aucune feuille de temps ne correspond au détails fournis",
INVALID_EXPENSE: "Une dépense contient des données manquantes ou corrompues",
EXPENSE_NOT_FOUND: "Aucune dépense ne correspond aux détails fournis",
},
}, },
timesheet_approvals: { timesheet_approvals: {

View File

@ -34,10 +34,11 @@
v-model="email" v-model="email"
dense dense
outlined outlined
color="accent"
label-color="accent" label-color="accent"
class="rounded-5 inset-shadow bg-blue-grey-1" class="rounded-5 inset-shadow bg-white"
label-slot label-slot
input-class="text-weight-medium text-h6" input-class="text-h6 text-dark"
> >
<template #label> <template #label>
<span class="text-weight-bolder text-uppercase text-overline"> {{ $t('login.email') }} </span> <span class="text-weight-bolder text-uppercase text-overline"> {{ $t('login.email') }} </span>

View File

@ -20,7 +20,7 @@
dense dense
:stack-label="!isEditing" :stack-label="!isEditing"
autogrow autogrow
filled :standout="$q.dark.isActive ? 'bg-blue-grey-3' : 'bg-blue-grey-9'"
debounce="500" debounce="500"
label-color="accent" label-color="accent"
class="q-ma-xs text-uppercase" class="q-ma-xs text-uppercase"

View File

@ -16,7 +16,7 @@
v-model="model" v-model="model"
dense dense
:stack-label="!isEditing" :stack-label="!isEditing"
filled :standout="$q.dark.isActive ? 'bg-blue-grey-3' : 'bg-blue-grey-9'"
label-color="accent" label-color="accent"
class="q-ma-xs text-h6 text-uppercase" class="q-ma-xs text-h6 text-uppercase"
popup-content-class="text-weight-medium text-h6" popup-content-class="text-weight-medium text-h6"

View File

@ -11,13 +11,20 @@
}>(); }>();
const date_format_options = { day: 'numeric', month: 'long', year: 'numeric', }; const date_format_options = { day: 'numeric', month: 'long', year: 'numeric', };
</script> </script>
<template> <template>
<div class="column q-mt-lg text-uppercase text-center text-weight-bolder text-h4"> <div class="column q-mt-lg text-uppercase text-center text-weight-bolder text-h4">
<span class="col">{{ $t(title) }}</span> <span class="col">{{ $t(title) }}</span>
<transition
enter-active-class="animated fadeInDown"
leave-active-class="animated fadeOutDown"
mode="out-in"
>
<div <div
:key="startDate"
v-if="startDate.length > 0" v-if="startDate.length > 0"
class="col row flex-center full-width q-py-none q-my-none" class="col row flex-center full-width q-py-none q-my-none"
> >
@ -31,5 +38,6 @@
{{ $d(date.extractDate(endDate, 'YYYY-MM-DD'), date_format_options) }} {{ $d(date.extractDate(endDate, 'YYYY-MM-DD'), date_format_options) }}
</div> </div>
</div> </div>
</transition>
</div> </div>
</template> </template>

View File

@ -7,9 +7,10 @@
<q-btn-dropdown <q-btn-dropdown
push push
rounded rounded
class="q-mr-md bg-white text-primary"
:label="$t('shared.label.filter')"
icon="filter_alt" icon="filter_alt"
color="accent"
:label="$t('shared.label.filter')"
class="q-mr-md"
/> />
<!-- Search bar --> <!-- Search bar -->
@ -21,14 +22,14 @@
debounce="300" debounce="300"
class="right-rounded" class="right-rounded"
:label="$t('shared.label.search')" :label="$t('shared.label.search')"
label-color="primary" label-color="accent"
bg-color="white" bg-color="white"
color="primary" color="accent"
> >
<template #prepend> <template #prepend>
<q-icon <q-icon
name="search" name="search"
color="primary" color="accent"
/> />
</template> </template>
</q-input> </q-input>

View File

@ -5,11 +5,11 @@
/* eslint-disable */ /* eslint-disable */
import { provide, ref } from 'vue'; import { provide, ref } from 'vue';
import { useTimesheetStore } from 'src/stores/timesheet-store'; import { useTimesheetStore } from 'src/stores/timesheet-store';
import DetailedDialogChartHoursWorked from 'src/modules/timesheet-approval/components/details-crud-dialog-chart-hours-worked.vue'; import DetailsDialogChartHoursWorked from 'src/modules/timesheet-approval/components/details-dialog-chart-hours-worked.vue';
import DetailedDialogChartShiftTypes from 'src/modules/timesheet-approval/components/details-crud-dialog-chart-shift-types.vue'; import DetailsDialogChartShiftTypes from 'src/modules/timesheet-approval/components/details-dialog-chart-shift-types.vue';
import DetailedDialogChartExpenses from 'src/modules/timesheet-approval/components/details-crud-dialog-chart-expenses.vue'; import DetailsDialogChartExpenses from 'src/modules/timesheet-approval/components/details-dialog-chart-expenses.vue';
import TimesheetWrapper from 'src/modules/timesheets/components/timesheet-wrapper.vue'; import TimesheetWrapper from 'src/modules/timesheets/components/timesheet-wrapper.vue';
import ExpenseCrudDialogList from 'src/modules/timesheets/components/expense-crud-dialog-list.vue'; import ExpenseDialogList from 'src/modules/timesheets/components/expense-dialog-list.vue';
const { employeeEmail } = defineProps<{ const { employeeEmail } = defineProps<{
employeeEmail: string; employeeEmail: string;
@ -48,17 +48,17 @@
:horizontal="!$q.screen.lt.md" :horizontal="!$q.screen.lt.md"
class=" col-auto q-px-sm no-wrap" class=" col-auto q-px-sm no-wrap"
> >
<DetailedDialogChartHoursWorked <DetailsDialogChartHoursWorked
:key="render_key" :key="render_key"
class="col" class="col"
/> />
<DetailedDialogChartShiftTypes <DetailsDialogChartShiftTypes
:key="render_key + 1" :key="render_key + 1"
class="col-2 q-ma-lg" class="col-2 q-ma-lg"
/> />
<DetailedDialogChartExpenses <DetailsDialogChartExpenses
:key="render_key + 2" :key="render_key + 2"
class="col" class="col"
/> />
@ -66,7 +66,7 @@
<q-card-section class="col-auto"> <q-card-section class="col-auto">
<q-separator /> <q-separator />
<ExpenseCrudDialogList <ExpenseDialogList
horizontal horizontal
:employee-email="employeeEmail" :employee-email="employeeEmail"
/> />

View File

@ -42,18 +42,6 @@
await timesheet_store.getTimesheetsByEmployeeEmail(employee_email); await timesheet_store.getTimesheetsByEmployeeEmail(employee_email);
// await expenses_store.getPayPeriodExpensesByTimesheetId(employee_email); // await expenses_store.getPayPeriodExpensesByTimesheetId(employee_email);
}; };
const getListModeTextColor = (type: string): string => {
console.log('type: ', type);
if (IS_ABNORMAL_SHIFT.includes(type)) {
return ' text-negative text-weight-bolder';
}
else if (IS_PTO.includes(type)) {
return ' text-warning text-weight-bold';
}
return '';
}
</script> </script>
<template> <template>
@ -93,8 +81,8 @@
v-model="is_grid_mode" v-model="is_grid_mode"
push push
color="white" color="white"
text-color="primary" text-color="accent"
toggle-color="primary" toggle-color="accent"
class="q-mr-md" class="q-mr-md"
:options="[ :options="[
{ icon: 'grid_view', value: true }, { icon: 'grid_view', value: true },
@ -133,7 +121,6 @@
> >
<span <span
v-if="(props.value > 0 && typeof props.value !== 'boolean') || typeof props.value === 'string'" v-if="(props.value > 0 && typeof props.value !== 'boolean') || typeof props.value === 'string'"
:class="getListModeTextColor(props.col.name)"
>{{ props.value }}</span> >{{ props.value }}</span>
<q-icon <q-icon
v-if="typeof props.value === 'boolean'" v-if="typeof props.value === 'boolean'"
@ -155,7 +142,7 @@
<!-- Template for custome failed-to-load state --> <!-- Template for custome failed-to-load state -->
<template #no-data="{ message, filter }"> <template #no-data="{ message, filter }">
<div class="full-width column items-center text-primary q-gutter-sm"> <div class="full-width column items-center text-accent q-gutter-sm">
<q-icon <q-icon
size="4em" size="4em"
:name="filter ? 'filter_alt_off' : 'error_outline'" :name="filter ? 'filter_alt_off' : 'error_outline'"

View File

@ -2,51 +2,75 @@
setup setup
lang="ts" lang="ts"
> >
import { date } from 'quasar';
import { computed, inject, ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { computed, ref, watch } from 'vue';
import { deepEqual } from 'src/utils/deep-equal';
import { useUiStore } from 'src/stores/ui-store'; import { useUiStore } from 'src/stores/ui-store';
import { useExpensesStore } from 'src/stores/expense-store'; import { useExpensesStore } from 'src/stores/expense-store';
import { useTimesheetStore } from 'src/stores/timesheet-store'; import { useTimesheetStore } from 'src/stores/timesheet-store';
import { useExpensesApi } from 'src/modules/timesheets/composables/use-expense-api'; import { useExpensesApi } from 'src/modules/timesheets/composables/use-expense-api';
import { useExpenseRules } from 'src/modules/timesheets/utils/expense.util'; import { convertToMonetaryAmount, getExpenseIcon, useExpenseRules } from 'src/modules/timesheets/utils/expense.util';
import { Expense, EXPENSE_TYPE, TYPES_WITH_AMOUNT_ONLY } from 'src/modules/timesheets/models/expense.models'; import { type ExpenseType, TYPES_WITH_AMOUNT_ONLY } from 'src/modules/timesheets/models/expense.models';
interface ExpenseOption {
label: string;
value: ExpenseType;
icon: string;
}
const { t } = useI18n(); const { t } = useI18n();
const ui_store = useUiStore(); const ui_store = useUiStore();
const timesheet_store = useTimesheetStore(); const timesheet_store = useTimesheetStore();
const expenses_store = useExpensesStore(); const expenses_store = useExpensesStore();
const expenses_api = useExpensesApi(); const expenses_api = useExpensesApi();
const files = defineModel<File[] | null>('files'); const files = defineModel<File[] | null>('files');
const is_navigator_open = ref(false); const is_navigator_open = ref(false);
const is_initial_expense = ref(true);
const COMMENT_MAX_LENGTH = 280; const COMMENT_MAX_LENGTH = 280;
const employee_email = inject<string>('employeeEmail');
const rules = useExpenseRules(t); const rules = useExpenseRules(t);
const period_start_date = computed(() => timesheet_store.pay_period?.period_start.replaceAll('-', '/') ?? ''); 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 period_end_date = computed(() => timesheet_store.pay_period?.period_end.replaceAll('-', '/') ?? '');
const expense_options: ExpenseOption[] = [
{label: t('timesheet.expense.types.PER_DIEM'), value: 'PER_DIEM', icon: getExpenseIcon('PER_DIEM')},
{label: t('timesheet.expense.types.EXPENSES'), value: 'EXPENSES', icon: getExpenseIcon('EXPENSES')},
{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(expense_options.find(expense => expense.value == expenses_store.current_expense.type));
const emit = defineEmits<{
'onClickUpdateCancel': [void];
'onClickSaveUpdates': [void];
}>();
const openDatePicker = () => { const openDatePicker = () => {
is_navigator_open.value = true; is_navigator_open.value = true;
if (timesheet_store.pay_period !== undefined) { if (expenses_store.current_expense.date === undefined) {
expenses_store.current_expense.date = timesheet_store.pay_period.period_start; expenses_store.current_expense.date = timesheet_store.pay_period?.period_start ?? '';
} }
console.log('current pay period start date: ', period_start_date.value);
console.log('current pay period end date: ', period_end_date.value);
}; };
const cancelUpdateMode = () => { const closeDatePicker = (date: string) => {
expenses_store.current_expense = new Expense(date.formatDate(new Date(), 'YYYY-MM-DD')); is_navigator_open.value = false;
expenses_store.initial_expense = new Expense(date.formatDate(new Date(), 'YYYY-MM-DD')); expenses_store.current_expense.date = date;
expenses_store.mode = 'create'; }
};
const requestExpenseCreationOrUpdate = async () => { const requestExpenseCreationOrUpdate = async () => {
if (expenses_store.mode === 'create') await expenses_api.createExpenseByEmployeeEmail(employee_email ?? '', expenses_store.current_expense?.date ?? ''); await expenses_api.upsertExpense(expenses_store.current_expense);
else await expenses_api.updateExpenseByEmployeeEmail(employee_email ?? '', expenses_store.current_expense?.date ?? '');
if (expenses_store.current_expense.id) {
emit('onClickSaveUpdates');
}
}; };
watch(expenses_store.current_expense, () => {
is_initial_expense.value = deepEqual(expenses_store.current_expense, expenses_store.initial_expense);
});
</script> </script>
<template> <template>
@ -55,25 +79,30 @@
:key="expenses_store.current_expense.id" :key="expenses_store.current_expense.id"
flat flat
@submit.prevent="requestExpenseCreationOrUpdate" @submit.prevent="requestExpenseCreationOrUpdate"
class="full-width"
> >
<div <div
class="text-uppercase text-weight-medium q-pt-sm q-px-lg q-ma-sm" class="text-uppercase text-weight-medium q-pt-sm q-ma-sm"
:class="expenses_store.mode === 'create' ? '' : 'invisible'" :class="expenses_store.mode === 'create' ? 'q-px-lg' : 'invisible'"
> >
{{ $t('timesheet.expense.add_expense') }} {{ $t('timesheet.expense.add_expense') }}
</div> </div>
<div class="row justify-between items-start rounded-5 q-px-lg q-pb-sm"> <div
class="row justify-between items-start rounded-5 q-pb-sm"
:class="expenses_store.mode === 'create' ? 'q-px-lg' : ''"
>
<!-- date selection input --> <!-- date selection input -->
<q-input <q-input
v-model="expenses_store.current_expense.date" v-model="expenses_store.current_expense.date"
dense dense
outlined type="date"
borderless
readonly readonly
stack-label stack-label
color="primary" color="primary"
class="col q-px-xs" class="col-auto q-px-xs"
input-class="text-weight-medium" input-class="text-weight-medium"
input-style="font-size: 1.2em;" input-style="font-size: 1em;"
:label="$t('timesheet.expense.date')" :label="$t('timesheet.expense.date')"
> >
<template #prepend> <template #prepend>
@ -96,7 +125,7 @@
mask="YYYY-MM-DD" mask="YYYY-MM-DD"
event-color="accent" event-color="accent"
:options="date => date >= period_start_date && date <= period_end_date" :options="date => date >= period_start_date && date <= period_end_date"
@update:model-value="is_navigator_open = false" @update:model-value="closeDatePicker"
/> />
</q-dialog> </q-dialog>
</template> </template>
@ -110,12 +139,12 @@
<!-- expenses type selection --> <!-- expenses type selection -->
<q-select <q-select
v-model="expenses_store.current_expense.type" v-model="expense_selected"
:options="EXPENSE_TYPE"
standout="bg-blue-grey-9" standout="bg-blue-grey-9"
dense dense
emit-value :options="expense_options"
hide-dropdown-icon hide-dropdown-icon
stack-label
label-slot label-slot
class="col q-px-xs" class="col q-px-xs"
color="primary" color="primary"
@ -126,7 +155,7 @@
popup-content-class="text-uppercase text-weight-bold text-center rounded-5" popup-content-class="text-uppercase text-weight-bold text-center rounded-5"
popup-content-style="border: 2px solid var(--q-accent)" popup-content-style="border: 2px solid var(--q-accent)"
:rules="[rules.typeRequired]" :rules="[rules.typeRequired]"
:option-label="label => $t(`timesheet.expense.types.${label}`)" @update:model-value="option => expenses_store.current_expense.type = option.value"
> >
<template #label> <template #label>
<span class="text-weight-bold text-accent text-uppercase text-caption"> <span class="text-weight-bold text-accent text-uppercase text-caption">
@ -136,13 +165,18 @@
<template #selected-item="scope"> <template #selected-item="scope">
<div <div
class="row flex-center text-weight-bold q-ma-none q-pa-none no-wrap ellipsis full-width" class="row items-center text-weight-bold q-ma-none q-pa-none no-wrap ellipsis full-width"
:class="ui_store.is_mobile_mode ? 'items-center full-height' : 'flex-center'" :class="ui_store.is_mobile_mode ? 'full-height' : ''"
:tabindex="scope.tabindex" :tabindex="scope.tabindex"
> >
<q-icon
:name="scope.opt.icon"
size="xs"
class="col-auto q-mx-xs"
/>
<span <span
style="line-height: 0.9em;" style="line-height: 1em;"
class="col-auto ellipsis" class="col-auto ellipsis text-uppercase"
>{{ scope.opt.label }}</span> >{{ scope.opt.label }}</span>
</div> </div>
</template> </template>
@ -153,16 +187,18 @@
<q-input <q-input
key="amount" key="amount"
v-model.number="expenses_store.current_expense.amount" v-model.number="expenses_store.current_expense.amount"
filled standout="bg-blue-grey-9"
input-class="text-right"
dense dense
stack-label
color="primary"
class="col q-px-xs"
label-slot label-slot
stack-label
suffix="$" suffix="$"
color="primary"
class="col-auto q-px-xs"
input-class="text-right text-weight-bold"
:input-style="'font-size: 1.2em;'"
lazy-rules="ondemand" lazy-rules="ondemand"
:rules="[rules.amountRequired]" :rules="[rules.amountRequired]"
@blur="expenses_store.current_expense.amount = convertToMonetaryAmount(expenses_store.current_expense.amount)"
> >
<template #label> <template #label>
<span class="text-weight-bold text-accent text-uppercase text-caption"> <span class="text-weight-bold text-accent text-uppercase text-caption">
@ -177,7 +213,7 @@
<q-input <q-input
key="mileage" key="mileage"
v-model.number="expenses_store.current_expense.mileage" v-model.number="expenses_store.current_expense.mileage"
filled standout="bg-blue-grey-9"
input-class="text-right" input-class="text-right"
dense dense
stack-label stack-label
@ -200,14 +236,13 @@
<!-- employee comment input --> <!-- employee comment input -->
<q-input <q-input
v-model="expenses_store.current_expense.comment" v-model="expenses_store.current_expense.comment"
filled standout="bg-blue-grey-9"
dense dense
stack-label stack-label
label-slot label-slot
color="primary" color="primary"
type="text" type="text"
class="col q-px-sm" class="col q-px-sm"
:counter="true"
:maxlength="COMMENT_MAX_LENGTH" :maxlength="COMMENT_MAX_LENGTH"
lazy-rules="ondemand" lazy-rules="ondemand"
:rules="[rules.commentRequired]" :rules="[rules.commentRequired]"
@ -222,8 +257,8 @@
<!-- import attach file section --> <!-- import attach file section -->
<q-file <q-file
v-model="files" v-model="files"
standout="bg-blue-grey-9"
dense dense
filled
use-chips use-chips
multiple multiple
stack-label stack-label
@ -235,7 +270,7 @@
<q-icon <q-icon
name="attach_file" name="attach_file"
size="sm" size="sm"
color="primary" color="accent"
/> />
</template> </template>
@ -257,15 +292,17 @@
icon="clear" icon="clear"
color="negative" color="negative"
:label="$q.screen.gt.sm ? $t('shared.label.cancel') : ''" :label="$q.screen.gt.sm ? $t('shared.label.cancel') : ''"
@click="cancelUpdateMode" @click="$emit('onClickUpdateCancel')"
/> />
<q-btn <q-btn
push push
color="accent" :disable="is_initial_expense"
:color="is_initial_expense ? 'grey-5' : 'accent'"
:icon="expenses_store.mode === 'update' ? 'save' : 'upload'" :icon="expenses_store.mode === 'update' ? 'save' : 'upload'"
:label="$q.screen.gt.sm ? (expenses_store.mode === 'update' ? $t('shared.label.update') : $t('shared.label.add')) : ''" :label="expenses_store.mode === 'update' ? $t('shared.label.update') : $t('shared.label.add')"
class="q-px-sm q-mb-sm q-mx-lg" class="q-px-sm "
:class="expenses_store.mode === 'create' ? 'q-mr-lg q-mb-md' : 'q-mb-sm q-ml-lg'"
type="submit" type="submit"
/> />
</div> </div>

View File

@ -34,7 +34,7 @@
icon="clear" icon="clear"
color="negative" color="negative"
class="col-auto" class="col-auto"
style="border-radius: 0 0 0 5px;" style="border-radius: 0 5px 0 10px;"
@click="expense_store.close" @click="expense_store.close"
/> />
</div> </div>
@ -48,20 +48,20 @@
{{ $t('timesheet.expense.total_amount') }} : {{ $t('timesheet.expense.total_amount') }} :
</span> </span>
<q-icon
v-else
name="payments"
size="sm"
color="accent"
class="col"
/>
<span <span
class="col-auto text-weight-light" class="col-auto text-weight-light"
style="font-size: 2.5em; line-height: 1em;" style="font-size: 2.5em; line-height: 1em;"
> >
{{ weekly_totals.expenses.toFixed(2) }} {{ weekly_totals.expenses.toFixed(2) }}
</span> </span>
<q-icon
v-if="$q.screen.lt.md"
name="attach_money"
size="md"
color="accent"
class="col q-ml-sm"
/>
</div> </div>
<div class="col-auto row items-center q-px-sm"> <div class="col-auto row items-center q-px-sm">
@ -72,20 +72,20 @@
{{ $t('timesheet.expense.total_mileage') }} : {{ $t('timesheet.expense.total_mileage') }} :
</span> </span>
<q-icon
v-else
name="drive_eta"
size="sm"
color="accent"
class="col"
/>
<span <span
class="col-auto text-weight-light" class="col-auto text-weight-light"
style="font-size: 2.5em; line-height: 1em;" style="font-size: 2.5em; line-height: 1em;"
> >
{{ weekly_totals.mileage.toFixed(1) }} {{ weekly_totals.mileage.toFixed(1) }}
</span> </span>
<q-icon
v-if="$q.screen.lt.md"
name="commute"
size="md"
color="accent"
class="col q-ml-sm"
/>
</div> </div>
</div> </div>
</div> </div>

View File

@ -3,7 +3,7 @@
lang="ts" lang="ts"
> >
import { date } from 'quasar'; import { date } from 'quasar';
import { computed, ref } from 'vue'; import { computed, ref, toRaw } 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';
@ -27,10 +27,10 @@
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) ? '' : '');
const background_style = computed(() => deepEqual(expense, expenses_store.current_expense) ? 'border: 3px solid var(--q-accent);' : '');
const approved_class = computed(() => expense.is_approved ? ' bg-accent text-white' : '') const approved_class = computed(() => expense.is_approved ? ' bg-accent text-white' : '')
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 is_showing_update_form = ref(false); const is_showing_update_form = ref(false);
const is_current_expense = computed(() => expense.id === expenses_store.current_expense.id);
const requestExpenseDeletion = async () => { const requestExpenseDeletion = async () => {
await expenses_api.deleteExpenseById(expense.id); await expenses_api.deleteExpenseById(expense.id);
@ -46,6 +46,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';
Object.assign(expense, toRaw(expenses_store.initial_expense))
expenses_store.current_expense = new Expense(date.formatDate(new Date(), 'YYYY-MM-DD')); expenses_store.current_expense = new Expense(date.formatDate(new Date(), 'YYYY-MM-DD'));
is_showing_update_form.value = false; is_showing_update_form.value = false;
return; return;
@ -56,6 +57,12 @@
expenses_store.initial_expense = unwrapAndClone(expense); expenses_store.initial_expense = unwrapAndClone(expense);
is_showing_update_form.value = true; is_showing_update_form.value = true;
} }
const onSaveUpdatesClicked = () => {
is_showing_update_form.value = false;
expenses_store.mode = 'create';
expenses_store.current_expense = new Expense(date.formatDate(new Date(), 'YYYY-MM-DD'));
}
</script> </script>
<template> <template>
@ -64,40 +71,22 @@
:clickable="horizontal" :clickable="horizontal"
class="column col-4 items-center q-my-sm q-py-none shadow-3 rounded-5 bg-dark" class="column col-4 items-center q-my-sm q-py-none shadow-3 rounded-5 bg-dark"
:class="background_class + approved_class" :class="background_class + approved_class"
:style="background_style"
@click="onExpenseClicked" @click="onExpenseClicked"
> >
<div class="row full-width items-center"> <div class="col row fit items-center">
<!-- avatar type icon section --> <!-- avatar type icon section -->
<q-item-section avatar> <q-item-section avatar>
<q-icon <q-icon
:name="getExpenseIcon(expense.type)" :name="getExpenseIcon(expense.type)"
:color="expense.is_approved ? 'white' : ($q.dark.isActive ? 'blue-grey-2' : 'primary')" :color="expense.is_approved ? 'white' : ($q.dark.isActive ? 'white' : 'primary')"
size="lg" size="lg"
>
<q-badge
v-if="expense.type === 'ON_CALL'"
floating
class="q-pa-none rounded-50 bg-white z-top"
>
<q-icon
name="shield"
size="xs"
:color="expense.is_approved ? 'white' : ($q.dark.isActive ? 'blue-grey-2' : 'primary')"
/> />
</q-badge>
</q-icon>
</q-item-section> </q-item-section>
<!-- amount or mileage section --> <!-- amount or mileage section -->
<q-item-section class="col col-md-2 text-weight-bold"> <q-item-section class="col col-md-2 text-weight-bold">
<q-item-label v-if="String(expense.type).trim().toUpperCase() === 'MILEAGE'"> <q-item-label v-if="expense.type === 'MILEAGE'">
<template v-if="typeof expense.mileage === 'number'">
{{ expense.mileage?.toFixed(1) }} km {{ 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) }}
@ -110,7 +99,10 @@
class="text-uppercase text-weight-light" class="text-uppercase text-weight-light"
:class="approved_class" :class="approved_class"
> >
{{ $d(new Date(expense.date), { month: 'short', day: 'numeric', weekday: 'long' }) }} {{ $d(date.extractDate(expense.date, 'YYYY-MM-DD'), {
month: 'short', day: 'numeric', weekday:
'long'
}) }}
</q-item-label> </q-item-label>
</q-item-section> </q-item-section>
@ -171,37 +163,58 @@
</q-item-label> </q-item-label>
</q-item-section> </q-item-section>
<q-item-section :side="$q.screen.gt.sm">
<q-item-section
:key="refresh_key"
side
:class="is_current_expense ? 'invisible' : ''"
>
<q-btn <q-btn
flat flat
dense
size="lg" size="lg"
icon="edit" icon="edit"
color="accent" color="accent"
:disable="expense.is_approved" :disable="expense.is_approved"
class="q-pa-none z-top" class="q-py-none z-top"
:class="expense.is_approved ? 'invisible no-pointer' : ''" :class="expense.is_approved ? 'invisible no-pointer' : ''"
@click.stop="onUpdateClicked" @click.stop="onUpdateClicked"
/> />
</q-item-section> </q-item-section>
<q-item-section :side="$q.screen.gt.sm"> <q-item-section
<q-btn side
flat :class="is_current_expense ? 'invisible' : ''"
>
<q-icon
v-if="expense.is_approved"
name="verified"
color="white"
size="lg" size="lg"
:icon="expense.is_approved ? 'verified' : 'close'" />
:color="expense.is_approved ? 'white' : 'negative'"
class="q-pa-none z-top" <q-btn
:class="expense.is_approved ? 'no-pointer' : ''" v-else
flat
dense
size="lg"
icon="close"
color="negative"
class="q-py-none z-top q-my-xs"
@click.stop="requestExpenseDeletion" @click.stop="requestExpenseDeletion"
/> />
</q-item-section> </q-item-section>
</div> </div>
<q-slide-transition <q-slide-transition
@hide="expenses_store.is_hiding_create_form = false" @hide="expenses_store.mode === 'update' ? null : expenses_store.is_hiding_create_form = false"
:duration="200" :duration="200"
> >
<ExpenseDialogForm v-if="is_showing_update_form && expenses_store.is_hiding_create_form" /> <ExpenseDialogForm
v-if="is_current_expense && expenses_store.is_hiding_create_form"
@on-click-update-cancel="onUpdateClicked"
@on-click-save-updates="onSaveUpdatesClicked"
/>
</q-slide-transition> </q-slide-transition>
</q-item> </q-item>
</template> </template>

View File

@ -5,6 +5,7 @@
import { computed } from 'vue'; import { computed } from 'vue';
import { useTimesheetStore } from 'src/stores/timesheet-store'; 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';
import ExpenseDialogListItemMobile from 'src/modules/timesheets/components/mobile/expense-dialog-list-item-mobile.vue';
const timesheet_store = useTimesheetStore(); const timesheet_store = useTimesheetStore();
@ -38,14 +39,25 @@
<q-separator spaced /> <q-separator spaced />
</q-item-label> </q-item-label>
<ExpenseDialogListItem <div
v-for="(expense, index) in expenses_list" v-for="(expense, index) in expenses_list"
:key="index" :key="index"
>
<ExpenseDialogListItemMobile
v-if="$q.screen.lt.md"
v-model="expense.is_approved" v-model="expense.is_approved"
:index="index" :index="index"
:expense="expense" :expense="expense"
:horizontal="horizontal" :horizontal="horizontal"
/> />
</q-list>
<ExpenseDialogListItem
v-else
v-model="expense.is_approved"
:index="index"
:expense="expense"
:horizontal="horizontal"
/>
</div>
</q-list>
</template> </template>

View File

@ -5,6 +5,7 @@
import { useExpensesStore } from 'src/stores/expense-store'; import { useExpensesStore } from 'src/stores/expense-store';
import ExpenseDialogList from 'src/modules/timesheets/components/expense-dialog-list.vue'; import ExpenseDialogList from 'src/modules/timesheets/components/expense-dialog-list.vue';
import ExpenseDialogForm from 'src/modules/timesheets/components/expense-dialog-form.vue'; import ExpenseDialogForm from 'src/modules/timesheets/components/expense-dialog-form.vue';
import ExpenseDialogFormMobile from 'src/modules/timesheets/components/mobile/expense-dialog-form-mobile.vue';
import ExpenseDialogHeader from 'src/modules/timesheets/components/expense-dialog-header.vue'; import ExpenseDialogHeader from 'src/modules/timesheets/components/expense-dialog-header.vue';
const expense_store = useExpensesStore(); const expense_store = useExpensesStore();
@ -18,8 +19,10 @@
transition-hide="jump-down" transition-hide="jump-down"
> >
<q-card <q-card
class="q-pa-none rounded-10 shadow-10 bg-secondary" class="q-pa-none rounded-10 shadow-10"
:class="$q.screen.lt.md ? ' bg-primary' : 'bg-secondary'"
style=" min-width: 70vw;" style=" min-width: 70vw;"
:style="$q.dark.isActive ? 'border: solid 2px var(--q-accent);' : ''"
> >
<q-inner-loading :showing="expense_store.is_loading"> <q-inner-loading :showing="expense_store.is_loading">
<q-spinner size="32px" /> <q-spinner size="32px" />
@ -38,8 +41,13 @@
<ExpenseDialogList /> <ExpenseDialogList />
<q-separator v-if="$q.screen.lt.md" spaced color="accent" size="2px" class="q-mx-md" />
<q-slide-transition @hide="expense_store.is_hiding_create_form = true" :duration="200"> <q-slide-transition @hide="expense_store.is_hiding_create_form = true" :duration="200">
<ExpenseDialogForm v-if="!expense_store.current_expense.is_approved && expense_store.mode !== 'update' && expense_store.is_hiding_create_form === false" /> <div v-if="!expense_store.current_expense.is_approved && expense_store.mode !== 'update' && expense_store.is_hiding_create_form === false">
<ExpenseDialogFormMobile v-if="$q.screen.lt.md" />
<ExpenseDialogForm v-else/>
</div>
</q-slide-transition> </q-slide-transition>
</q-card-section> </q-card-section>
</q-card> </q-card>

View File

@ -0,0 +1,315 @@
<script
setup
lang="ts"
>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useUiStore } from 'src/stores/ui-store';
import { useExpensesStore } from 'src/stores/expense-store';
import { useTimesheetStore } from 'src/stores/timesheet-store';
import { useExpensesApi } from 'src/modules/timesheets/composables/use-expense-api';
import { convertToMonetaryAmount, getExpenseIcon, useExpenseRules } from 'src/modules/timesheets/utils/expense.util';
import { type ExpenseType, TYPES_WITH_AMOUNT_ONLY } from 'src/modules/timesheets/models/expense.models';
interface ExpenseOption {
label: string;
value: ExpenseType;
icon: string;
}
const { t } = useI18n();
const ui_store = useUiStore();
const timesheet_store = useTimesheetStore();
const expenses_store = useExpensesStore();
const expenses_api = useExpensesApi();
const files = defineModel<File[] | null>('files');
const is_navigator_open = ref(false);
const is_showing_comment_dialog_mobile = 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_options: ExpenseOption[] = [
{ label: t('timesheet.expense.types.PER_DIEM'), value: 'PER_DIEM', icon: getExpenseIcon('PER_DIEM') },
{ label: t('timesheet.expense.types.EXPENSES'), value: 'EXPENSES', icon: getExpenseIcon('EXPENSES') },
{ 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(expense_options.find(expense => expense.value == expenses_store.current_expense.type));
const openDatePicker = () => {
is_navigator_open.value = true;
if (timesheet_store.pay_period !== undefined) {
expenses_store.current_expense.date = timesheet_store.pay_period.period_start;
}
};
const requestExpenseCreationOrUpdate = async () => {
await expenses_api.upsertExpense(expenses_store.current_expense);
};
defineEmits<{
'onClickUpdateCancel': [void];
}>();
</script>
<template>
<q-form
v-if="!timesheet_store.timesheets?.every(timesheet => timesheet.is_approved)"
:key="expenses_store.current_expense.id"
flat
@submit.prevent="requestExpenseCreationOrUpdate"
class="column full-width"
>
<!-- header -->
<div
class="col text-uppercase text-weight-medium text-h6 q-ma-xs"
:class="expenses_store.mode === 'create' ? 'q-px-md' : 'invisible'"
>
{{ $t('timesheet.expense.add_expense') }}
</div>
<div
class="col column items-start rounded-5 q-pb-sm"
:class="expenses_store.mode === 'create' ? 'q-px-md' : ''"
>
<!-- date and type row -->
<div class="col row q-my-xs full-width">
<!-- date selection input -->
<q-input
v-model="expenses_store.current_expense.date"
dense
type="date"
outlined
readonly
stack-label
hide-bottom-space
color="primary"
class="col-auto q-mr-sm"
input-class="text-weight-medium"
input-style="font-size: 1em;"
:label="$t('timesheet.expense.date')"
>
<template #prepend>
<q-btn
push
dense
icon="event"
color="accent"
class="q-mr-sm"
@click="openDatePicker"
/>
<q-dialog
v-model="is_navigator_open"
transition-show="jump-right"
transition-hide="jump-right"
>
<q-date
v-model="expenses_store.current_expense.date"
mask="YYYY-MM-DD"
event-color="accent"
:options="date => date >= period_start_date && date <= period_end_date"
@update:model-value="is_navigator_open = false"
/>
</q-dialog>
</template>
<template #label>
<span class="text-weight-bold text-accent text-uppercase text-caption">
{{ $t('timesheet.expense.date') }}
</span>
</template>
</q-input>
<!-- expenses type selection -->
<q-select
v-model="expense_selected"
standout="bg-blue-grey-9 text-white"
dense
:options="expense_options"
hide-dropdown-icon
stack-label
label-slot
hide-bottom-space
class="col"
color="primary"
:label="$t('timesheet.expense.type')"
:menu-offset="[0, 10]"
menu-anchor="bottom middle"
menu-self="top middle"
popup-content-class="text-uppercase text-weight-bold text-center rounded-5"
popup-content-style="border: 2px solid var(--q-accent)"
:rules="[rules.typeRequired]"
@update:model-value="option => expenses_store.current_expense.type = option.value"
>
<template #label>
<span class="text-weight-bold text-accent text-uppercase text-caption">
{{ $t('timesheet.expense.type') }}
</span>
</template>
<template #selected-item="scope">
<div
class="row items-center text-weight-bold q-ma-none q-pa-none full-width"
:class="ui_store.is_mobile_mode ? 'full-height' : ''"
:tabindex="scope.tabindex"
>
<q-icon
:name="scope.opt.icon"
size="sm"
class="col-auto q-mx-xs"
/>
<span class="col text-uppercase ellipsis">{{ scope.opt.label }}</span>
</div>
</template>
</q-select>
</div>
<!-- amount and comment row -->
<div class="col row q-my-xs full-width">
<!-- amount input -->
<div class="col q-mr-sm">
<q-input
v-if="TYPES_WITH_AMOUNT_ONLY.includes(expenses_store.current_expense?.type ?? 'EXPENSES')"
v-model.number="expenses_store.current_expense.amount"
key="amount"
standout="bg-blue-grey-9"
dense
label-slot
stack-label
hide-bottom-space
suffix="$"
color="primary"
input-class="text-right text-weight-bold"
:input-style="'font-size: 1.2em;'"
lazy-rules="ondemand"
:rules="[rules.amountRequired]"
@blur="expenses_store.current_expense.amount = convertToMonetaryAmount(expenses_store.current_expense.amount)"
>
<template #label>
<span class="text-weight-bold text-accent text-uppercase text-caption">
{{ $t('timesheet.expense.amount') }}
</span>
</template>
</q-input>
<!-- mileage input -->
<q-input
v-else
v-model.number="expenses_store.current_expense.mileage"
key="mileage"
standout="bg-blue-grey-9"
input-class="text-right"
dense
stack-label
clearable
hide-bottom-space
color="primary"
label-slot
suffix="km"
lazy-rules="ondemand"
:rules="[rules.mileageRequired]"
>
<template #label>
<span class="text-weight-bold text-accent text-uppercase text-caption">
{{ $t('timesheet.expense.mileage') }}
</span>
</template>
</q-input>
</div>
<!-- employee comment input -->
<q-btn
push
color="accent"
:icon="expenses_store.current_expense.comment ? 'chat' : 'chat_bubble_outline'"
@click="is_showing_comment_dialog_mobile = true"
class="col-auto"
/>
<q-dialog v-model="is_showing_comment_dialog_mobile">
<q-card class="full-width bg-secondary rounded-10">
<q-card-section class="q-pa-none">
<span
class="text-weight-bold text-accent text-uppercase text-caption"
style="font-size: 1.5em;"
>
{{ $t('timesheet.expense.employee_comment') }}
</span>
</q-card-section>
<q-card-section class="q-pa-none bg-primary rounded-10">
<q-input
v-model="expenses_store.current_expense.comment"
standout="bg-blue-grey-9"
dense
hide-bottom-space
color="primary"
type="textarea"
:maxlength="COMMENT_MAX_LENGTH"
lazy-rules="ondemand"
:rules="[rules.commentRequired]"
>
</q-input>
</q-card-section>
</q-card>
</q-dialog>
</div>
<!-- import attach file section -->
<q-file
v-model="files"
standout="bg-blue-grey-9"
dense
use-chips
multiple
stack-label
:label="$t('timesheet.expense.hints.attach_file')"
class="col full-width q-my-xs"
>
<template #prepend>
<q-icon
name="attach_file"
size="sm"
color="accent"
/>
</template>
<template #label>
<span class="text-weight-bold text-accent text-uppercase text-caption">
{{ $t('timesheet.expense.hints.attach_file') }}
</span>
</template>
</q-file>
</div>
<div class="col row full-width items-center">
<q-space />
<q-btn
v-if="expenses_store.mode === 'update'"
flat
dense
class="col-auto"
icon="clear"
color="negative"
:label="$q.screen.gt.sm ? $t('shared.label.cancel') : ''"
@click="$emit('onClickUpdateCancel')"
/>
<q-btn
push
color="accent"
:icon="expenses_store.mode === 'update' ? 'save' : 'upload'"
:label="expenses_store.mode === 'update' ? $t('shared.label.update') : $t('shared.label.add')"
class="q-px-sm"
:class="expenses_store.mode === 'create' ? 'q-mr-md q-mb-md' : 'q-mb-sm q-ml-lg'"
type="submit"
/>
</div>
</q-form>
</template>

View File

@ -0,0 +1,150 @@
<script
setup
lang="ts"
>
import { date } from 'quasar';
import { computed, ref } from 'vue';
import { unwrapAndClone } from 'src/utils/unwrap-and-clone';
import { deepEqual } from 'src/utils/deep-equal';
import { useExpensesApi } from 'src/modules/timesheets/composables/use-expense-api';
import { useExpensesStore } from 'src/stores/expense-store';
import { getExpenseIcon } from 'src/modules/timesheets/utils/expense.util';
import { Expense } from 'src/modules/timesheets/models/expense.models';
import ExpenseDialogFormMobile from 'src/modules/timesheets/components/mobile/expense-dialog-form-mobile.vue';
const { expense, horizontal = false } = defineProps<{
expense: Expense;
index: number;
horizontal?: boolean;
}>();
const expenses_store = useExpensesStore();
const expenses_api = useExpensesApi();
const refresh_key = ref(1);
const background_class = computed(() => deepEqual(expense, expenses_store.current_expense) ? '' : '');
const approved_class = computed(() => expense.is_approved ? ' bg-accent text-white' : '')
const is_showing_update_form = ref(false);
const requestExpenseDeletion = async () => {
await expenses_api.deleteExpenseById(expense.id);
}
const onUpdateClicked = () => {
if (expense.is_approved) return;
if (deepEqual(expense, expenses_store.current_expense)) {
expenses_store.mode = 'create';
expenses_store.current_expense = new Expense(date.formatDate(new Date(), 'YYYY-MM-DD'));
is_showing_update_form.value = false;
return;
}
expenses_store.mode = 'update';
expenses_store.current_expense = expense;
expenses_store.initial_expense = unwrapAndClone(expense);
is_showing_update_form.value = true;
}
</script>
<template>
<div class="column bg-dark rounded-5 q-my-sm full-width">
<q-slide-item
right-color="negative"
class="rounded-5 bg-dark full-width"
@right="requestExpenseDeletion"
>
<template
#right
v-if="$q.screen.lt.md && !expenses_store.is_hiding_create_form && !expense.is_approved"
>
<q-icon name="delete" />
</template>
<q-item
:key="refresh_key"
clickable
class="row q-py-none q-pa-xs rounded-5 full-width"
:class="background_class + approved_class"
@click="onUpdateClicked"
>
<div class="column col">
<!-- date label -->
<div class="col-auto row items-center q-pl-xs">
<q-icon
name="calendar_month"
size="sm"
class="col-auto"
/>
<span
class="col text-uppercase text-weight-light full-width q-pl-sm text-h6"
:class="approved_class"
>
{{ $d(
date.extractDate(expense.date, 'YYYY-MM-DD'),
{ month: 'long', day: 'numeric' }
) }}
</span>
</div>
<div class="col row full-width items-center">
<!-- avatar type icon section -->
<q-icon
:name="getExpenseIcon(expense.type)"
:color="expense.is_approved ? 'white' : ($q.dark.isActive ? 'white' : 'primary')"
size="lg"
/>
<!-- amount or mileage section -->
<q-item-section class="col text-weight-bold text-h6">
<q-item-label v-if="expense.type === 'MILEAGE'">
{{ expense.mileage?.toFixed(1) }} km
</q-item-label>
<q-item-label v-else>
$ {{ expense.amount.toFixed(2) }}
</q-item-label>
</q-item-section>
<q-space v-if="horizontal" />
<!-- attachment file icon -->
<q-item-section avatar>
<q-btn
push
:color="expense.is_approved ? 'white' : 'accent'"
:text-color="expense.is_approved ? 'accent' : 'white'"
class="col-auto q-mx-sm q-px-sm q-pb-sm"
icon="attach_file"
/>
</q-item-section>
</div>
</div>
<div
class="col-auto q-px-sm"
:class="expense.is_approved ? '' : 'invisible'"
>
<q-icon
v-if="expense.is_approved"
name="verified"
color="white"
size="lg"
class="full-height"
/>
</div>
</q-item>
</q-slide-item>
<q-slide-transition
@hide="expenses_store.is_hiding_create_form = false"
:duration="200"
>
<ExpenseDialogFormMobile
v-if="is_showing_update_form && expenses_store.is_hiding_create_form"
class="q-mt-sm q-pa-sm"
@on-click-update-cancel="onUpdateClicked"
/>
</q-slide-transition>
</div>
</template>

View File

@ -3,8 +3,7 @@
lang="ts" lang="ts"
> >
/* eslint-disable*/ /* eslint-disable*/
import { onBeforeUnmount, onMounted, ref, toRaw, useTemplateRef } from 'vue'; import { onBeforeUnmount, onMounted, ref, useTemplateRef } from 'vue';
import { unwrapAndClone } from 'src/utils/unwrap-and-clone';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { QSelect } from 'quasar'; import { QSelect } from 'quasar';
import { Shift, ShiftType } from 'src/modules/timesheets/models/shift.models'; import { Shift, ShiftType } from 'src/modules/timesheets/models/shift.models';
@ -22,27 +21,28 @@
const COMMENT_LENGTH_MAX = 280; const COMMENT_LENGTH_MAX = 280;
const SHIFT_OPTIONS: ShiftOption[] = [ const SHIFT_OPTIONS: ShiftOption[] = [
{ label: t('timesheet.shift.types.REGULAR'), value: 'REGULAR', icon: 'wb_sunny', icon_color: '' }, { label: t('timesheet.shift.types.REGULAR'), value: 'REGULAR', icon: 'wb_sunny', icon_color: 'blue-grey-3' },
{ label: t('timesheet.shift.types.EVENING'), value: 'EVENING', icon: 'bedtime', icon_color: 'indigo-8' }, { label: t('timesheet.shift.types.EVENING'), value: 'EVENING', icon: 'bedtime', icon_color: 'indigo-8' },
{ label: t('timesheet.shift.types.EMERGENCY'), value: 'EMERGENCY', icon: 'ring_volume', icon_color: 'red-8' }, { label: t('timesheet.shift.types.EMERGENCY'), value: 'EMERGENCY', icon: 'ring_volume', icon_color: 'red-8' },
{ label: t('timesheet.shift.types.VACATION'), value: 'VACATION', icon: 'beach_access', icon_color: 'yellow-8' }, { label: t('timesheet.shift.types.VACATION'), value: 'VACATION', icon: 'beach_access', icon_color: 'yellow-8' },
{ label: t('timesheet.shift.types.HOLIDAY'), value: 'HOLIDAY', icon: 'forest', icon_color: 'green-8' }, { label: t('timesheet.shift.types.HOLIDAY'), value: 'HOLIDAY', icon: 'forest', icon_color: 'green-8' },
{ label: t('timesheet.shift.types.SICK'), value: 'SICK', icon: 'medication_liquid', icon_color: 'cyan-8' }, { label: t('timesheet.shift.types.SICK'), value: 'SICK', icon: 'medication_liquid', icon_color: 'light-blue-6' },
]; ];
const shift = defineModel<Shift>('shift', { required: true }); const shift = defineModel<Shift>('shift', { required: true });
const { dense = false, outlined = false } = defineProps<{ const { dense = false, hasShiftAfter = false, isTimesheetApproved = false } = defineProps<{
dense?: boolean; dense?: boolean;
outlined?: boolean; hasShiftAfter?: boolean;
isTimesheetApproved?: boolean;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
'saveComment': [comment: string, shift_id: number]; 'saveComment': [comment: string, shift_id: number];
'requestDelete': [void]; 'requestDelete': [void];
}>(); }>();
const is_showing_time_picker = ref(false);
const select_ref = useTemplateRef<QSelect>('select'); const select_ref = useTemplateRef<QSelect>('select');
const initial_shift = ref<Shift>(unwrapAndClone(toRaw(shift.value)))
let timer: NodeJS.Timeout; let timer: NodeJS.Timeout;
const shift_type_selected = ref(SHIFT_OPTIONS.find(option => option.value == shift.value.type)); const shift_type_selected = ref(SHIFT_OPTIONS.find(option => option.value == shift.value.type));
@ -85,8 +85,8 @@
<template> <template>
<q-slide-item <q-slide-item
right-color="negative" right-color="negative"
class="rounded-5 bg-transparent" class="rounded-5 transparent"
:class="ui_store.is_mobile_mode ? 'q-my-md' : ''" :class="ui_store.is_mobile_mode ? 'q-my-md' : 'q-mr-xs'"
@right="details => slideDeleteShift(details.reset)" @right="details => slideDeleteShift(details.reset)"
> >
<template <template
@ -95,13 +95,17 @@
> >
<q-icon name="delete" /> <q-icon name="delete" />
</template> </template>
<div :class="ui_store.is_mobile_mode ? 'column' : 'row'"> <div :class="ui_store.is_mobile_mode ? 'column' : 'row'">
<div class="row items-center text-uppercase rounded-5 bg-transparent q-mb-xs" :class="ui_store.is_mobile_mode ? 'col' : 'col-4'"> <div
class="row items-center text-uppercase rounded-5"
:class="ui_store.is_mobile_mode ? 'col q-mb-xs' : 'col-4'"
>
<!-- mobile comment button --> <!-- mobile comment button -->
<q-btn <q-btn
v-if="ui_store.is_mobile_mode && !dense" v-if="ui_store.is_mobile_mode && !dense"
:icon="shift.comment ? 'chat' : 'chat_bubble_outline'" :icon="shift.comment ? 'chat' : 'chat_bubble_outline'"
:text-color="shift.comment ? 'accent' : 'grey-5'" :text-color="shift.comment ? ((shift.is_approved || isTimesheetApproved) ? 'white' : 'accent') : 'grey-5'"
class="col-auto full-height q-mx-xs rounded-5 shadow-1" class="col-auto full-height q-mx-xs rounded-5 shadow-1"
> >
<q-popup-edit <q-popup-edit
@ -115,13 +119,13 @@
color="white" color="white"
v-model="scope.value" v-model="scope.value"
dense dense
:readonly="shift.is_approved" :readonly="(shift.is_approved || isTimesheetApproved)"
autofocus autofocus
counter counter
bottom-slots bottom-slots
:maxlength="COMMENT_LENGTH_MAX" :maxlength="COMMENT_LENGTH_MAX"
class="q-pb-lg" class="q-pb-lg"
:class="shift.is_approved ? 'cursor-not-allowed' : ''" :class="(shift.is_approved || isTimesheetApproved) ? 'cursor-not-allowed' : ''"
@keyup.enter="scope.set" @keyup.enter="scope.set"
> >
<template #append> <template #append>
@ -154,9 +158,10 @@
<q-select <q-select
ref="select" ref="select"
v-model="shift_type_selected" v-model="shift_type_selected"
standout="bg-blue-grey-9" :standout="$q.dark.isActive ? 'bg-blue-grey-3' : 'bg-blue-grey-9'"
dense dense
:readonly="shift.is_approved" :borderless="(shift.is_approved || isTimesheetApproved)"
:readonly="(shift.is_approved || isTimesheetApproved)"
:options-dense="!ui_store.is_mobile_mode" :options-dense="!ui_store.is_mobile_mode"
hide-dropdown-icon hide-dropdown-icon
:menu-offset="[0, 10]" :menu-offset="[0, 10]"
@ -164,6 +169,8 @@
menu-self="top middle" menu-self="top middle"
:options="SHIFT_OPTIONS" :options="SHIFT_OPTIONS"
class="col rounded-5 q-mx-xs bg-dark" class="col rounded-5 q-mx-xs bg-dark"
:class="(shift.is_approved || isTimesheetApproved) ? 'inset-shadow' : ''"
:style="(shift.is_approved || isTimesheetApproved) ? 'background-color: #0a7d32 !important;' : ''"
popup-content-class="text-uppercase text-weight-bold text-center rounded-5" popup-content-class="text-uppercase text-weight-bold text-center rounded-5"
popup-content-style="border: 2px solid var(--q-accent)" popup-content-style="border: 2px solid var(--q-accent)"
@blur="onBlurShiftTypeSelect" @blur="onBlurShiftTypeSelect"
@ -184,6 +191,7 @@
<span <span
style="line-height: 0.9em;" style="line-height: 0.9em;"
class="col-auto ellipsis" class="col-auto ellipsis"
:class="(shift.is_approved || isTimesheetApproved) ? 'text-white' : ''"
>{{ scope.opt.label }}</span> >{{ scope.opt.label }}</span>
</div> </div>
</template> </template>
@ -195,15 +203,17 @@
<q-input <q-input
v-model="shift.start_time" v-model="shift.start_time"
dense dense
:readonly="shift.is_approved" :borderless="(shift.is_approved || isTimesheetApproved)"
:readonly="(shift.is_approved || isTimesheetApproved)"
type="time" type="time"
:standout="$q.dark.isActive ? 'bg-blue-grey-9' : 'bg-blue-grey-1 text-white'" :standout="$q.dark.isActive ? 'bg-blue-grey-3' : 'bg-blue-grey-9'"
label-slot label-slot
label-color="accent" :label-color="(shift.is_approved || isTimesheetApproved) ? 'white' : 'accent'"
:input-class="'text-weight-medium ' + (shift.id === -2 ? 'text-white ' : ' ') + (shift.is_approved ? 'cursor-not-allowed' : '')" :input-class="'text-weight-medium ' + (shift.id === -2 ? 'text-white ' : ' ') + ((shift.is_approved || isTimesheetApproved) ? 'cursor-not-allowed text-white' : '')"
input-style="font-size: 1.2em;" input-style="font-size: 1.2em;"
class="col rounded-5 bg-dark" class="col rounded-5 bg-dark"
:class="(shift.id === -2 ? 'bg-negative ' : ' ') + (ui_store.is_mobile_mode ? 'q-mr-xs ' : 'q-mx-xs ') + (shift.is_approved ? 'cursor-not-allowed' : '')" :class="(shift.id === -2 ? 'bg-negative ' : ' ') + (ui_store.is_mobile_mode ? 'q-mr-xs ' : 'q-mx-xs ') + ((shift.is_approved || isTimesheetApproved) ? 'cursor-not-allowed q-px-xs transparent inset-shadow' : '')"
:style="(shift.is_approved || isTimesheetApproved) ? 'background-color: #0a7d32 !important;' : ''"
> >
<template #label> <template #label>
<span <span
@ -216,16 +226,18 @@
<!-- punch out field --> <!-- punch out field -->
<q-input <q-input
v-model="shift.end_time" v-model="shift.end_time"
:standout="$q.dark.isActive ? 'bg-blue-grey-3' : 'bg-blue-grey-9'"
dense dense
:readonly="shift.is_approved" :borderless="(shift.is_approved || isTimesheetApproved)"
:readonly="(shift.is_approved || isTimesheetApproved)"
type="time" type="time"
standout="bg-blue-grey-9"
label-slot label-slot
label-color="accent" :label-color="(shift.is_approved || isTimesheetApproved) ? 'white' : 'accent'"
:input-class="'text-weight-medium ' + (shift.id === -2 ? 'text-white ' : ' ') + (shift.is_approved ? 'cursor-not-allowed' : '')" :input-class="'text-weight-medium ' + (shift.id === -2 ? 'text-white ' : ' ') + ((shift.is_approved || isTimesheetApproved) ? 'cursor-not-allowed text-white' : '')"
input-style="font-size: 1.2em;" input-style="font-size: 1.2em;"
class="col rounded-5 bg-dark" class="col rounded-5 bg-dark"
:class="(shift.id === -2 ? 'bg-negative ' : ' ') + (ui_store.is_mobile_mode ? 'q-ml-xs ' : 'q-mx-xs ') + (shift.is_approved ? 'cursor-not-allowed' : '')" :class="(shift.id === -2 ? 'bg-negative ' : ' ') + (ui_store.is_mobile_mode ? 'q-ml-xs ' : 'q-mx-xs ') + ((shift.is_approved || isTimesheetApproved) ? 'cursor-not-allowed q-px-xs transparent inset-shadow' : '')"
:style="shift.is_approved ? 'background-color: #0a7d32 !important;' : ''"
> >
<template #label> <template #label>
<span <span
@ -248,10 +260,11 @@
<!-- desktop comment button --> <!-- desktop comment button -->
<q-btn <q-btn
v-else-if="!ui_store.is_mobile_mode" v-else-if="!ui_store.is_mobile_mode"
flat push
dense dense
:color="shift.is_approved ? 'accent' : 'dark'"
:icon="shift.comment ? 'chat' : 'chat_bubble_outline'" :icon="shift.comment ? 'chat' : 'chat_bubble_outline'"
:text-color="shift.comment ? 'accent' : 'grey-5'" :text-color="shift.is_approved ? '' : (shift.comment ? 'accent' : 'grey-5')"
class="col" class="col"
:class="ui_store.is_mobile_mode ? 'q-mt-xs bg-dark' : ''" :class="ui_store.is_mobile_mode ? 'q-mt-xs bg-dark' : ''"
> >
@ -317,4 +330,11 @@
</div> </div>
</div> </div>
</q-slide-item> </q-slide-item>
<q-separator
v-if="hasShiftAfter && ui_store.is_mobile_mode"
spaced
color="accent"
class="q-mx-md"
/>
</template> </template>

View File

@ -9,10 +9,9 @@
const shift_api = useShiftApi(); const shift_api = useShiftApi();
const { day, dense = false, outlined = false, approved = false } = defineProps<{ const { day, dense = false, approved = false } = defineProps<{
day: TimesheetDay; day: TimesheetDay;
dense?: boolean; dense?: boolean;
outlined?: boolean;
approved?: boolean; approved?: boolean;
}>(); }>();
@ -36,8 +35,9 @@
v-for="shift, shift_index in day.shifts" v-for="shift, shift_index in day.shifts"
:key="shift_index" :key="shift_index"
v-model:shift="day.shifts[shift_index]!" v-model:shift="day.shifts[shift_index]!"
:outlined="outlined" :is-timesheet-approved="approved"
:dense="dense" :dense="dense"
:has-shift-after="shift_index < day.shifts.length - 1"
@request-delete="deleteCurrentShift(shift)" @request-delete="deleteCurrentShift(shift)"
/> />
</div> </div>

View File

@ -2,6 +2,7 @@
setup setup
lang="ts" lang="ts"
> >
import { computed } from 'vue';
import { date } from 'quasar'; import { date } from 'quasar';
import { useUiStore } from 'src/stores/ui-store'; import { useUiStore } from 'src/stores/ui-store';
import { useTimesheetStore } from 'src/stores/timesheet-store'; import { useTimesheetStore } from 'src/stores/timesheet-store';
@ -15,6 +16,8 @@
const ui_store = useUiStore(); const ui_store = useUiStore();
const timesheet_store = useTimesheetStore(); const timesheet_store = useTimesheetStore();
const animation_style = computed(() => ui_store.is_mobile_mode ? 'fadeInLeft' : 'fadeInDown' );
const addNewShift = (day_shifts: Shift[], date: string, timesheet_id: number) => { const addNewShift = (day_shifts: Shift[], date: string, timesheet_id: number) => {
ui_store.focus_next_component = true; ui_store.focus_next_component = true;
const new_shift = new Shift; const new_shift = new Shift;
@ -47,24 +50,30 @@
v-for="timesheet, timesheet_index in timesheet_store.timesheets" v-for="timesheet, timesheet_index in timesheet_store.timesheets"
:key="timesheet.timesheet_id" :key="timesheet.timesheet_id"
class="col column" class="col column"
>
<transition-group
appear
:enter-active-class="`animated ${animation_style}`"
> >
<div <div
v-for="day, day_index in timesheet.days" v-for="day, day_index in timesheet.days"
:key="day.date" :key="day.date"
class="col-auto row rounded-10 q-ma-sm shadow-10" class="col-auto row rounded-10 q-ma-sm shadow-10"
:style="`animation-delay: ${day_index / 15}s;`"
> >
<div <div
v-if="ui_store.is_mobile_mode" v-if="ui_store.is_mobile_mode"
class="col column full-width" class="col column full-width"
> >
<q-card <q-card
class="rounded-10 bg-dark" class="rounded-10"
:style="ui_store.is_mobile_mode ? (getDayApproval(day) ? 'border: 3px solid var(--q-accent)' : 'border: 1px solid var(--q-accent);') : ''" :class="(getDayApproval(day) || timesheet.is_approved) ? 'bg-accent' : 'bg-dark'"
:style="ui_store.is_mobile_mode ? ((getDayApproval(day) || timesheet.is_approved) ? 'border: 6px inset var(--q-accent)' : 'border: 1px solid var(--q-accent);') : ''"
> >
<q-card-section <q-card-section
class="text-weight-bolder text-uppercase text-h6 q-py-xs" class="text-weight-bolder text-uppercase text-h6 q-py-xs"
:class="getDayApproval(day) ? 'bg-dark text-accent' : 'bg-primary text-white'" :class="(getDayApproval(day) || timesheet.is_approved) ? 'bg-dark text-white' : 'bg-primary text-white'"
style="line-height: 1em;" style="line-height: 1em;"
> >
<span> {{ $d(extractDate(day.date, 'YYYY-MM-DD'), { <span> {{ $d(extractDate(day.date, 'YYYY-MM-DD'), {
@ -79,7 +88,8 @@
> >
<ShiftListDay <ShiftListDay
outlined outlined
:approved="getDayApproval(day)" :animation-delay-multiplier="day_index"
:approved="(getDayApproval(day) || timesheet.is_approved)"
:day="day" :day="day"
@delete-unsaved-shift="deleteUnsavedShift(timesheet_index, day_index)" @delete-unsaved-shift="deleteUnsavedShift(timesheet_index, day_index)"
/> />
@ -87,7 +97,7 @@
<q-card-actions class="q-pa-none"> <q-card-actions class="q-pa-none">
<q-btn <q-btn
v-if="!getDayApproval(day)" v-if="!(getDayApproval(day) || timesheet.is_approved)"
square square
color="accent" color="accent"
icon="more_time" icon="more_time"
@ -98,7 +108,7 @@
</q-card-actions> </q-card-actions>
<q-badge <q-badge
v-if="getDayApproval(day)" v-if="(getDayApproval(day) || timesheet.is_approved)"
floating floating
class="transparent q-pa-none rounded-50" class="transparent q-pa-none rounded-50"
style="transform: translate(15px, -5px);" style="transform: translate(15px, -5px);"
@ -106,7 +116,7 @@
<q-icon <q-icon
name="verified" name="verified"
size="5em" size="5em"
color="accent" color="white"
/> />
</q-badge> </q-badge>
</q-card> </q-card>
@ -115,31 +125,35 @@
<div <div
v-else v-else
class="col row full-width" class="col row full-width"
:class="getDayApproval(day) ? 'rounded-10 bg-accent' : ''" :class="(getDayApproval(day) || timesheet.is_approved) ? 'rounded-10 bg-accent' : ''"
> >
<!-- List of shifts -->
<div <div
class="col row bg-dark" class="col row bg-dark"
:class="getDayApproval(day) ? 'bg-transparent' : ''" :class="(getDayApproval(day) || timesheet.is_approved) ? 'bg-transparent' : ''"
style="border-radius: 10px 0 0 10px;" style="border-radius: 10px 0 0 10px;"
> >
<!-- Date block --> <!-- Date block -->
<ShiftListDateWidget <ShiftListDateWidget
:display-date="day.date" :display-date="day.date"
:approved="getDayApproval(day)" :approved="(getDayApproval(day) || timesheet.is_approved)"
class="col-auto" class="col-auto"
/> />
<!-- List of shifts -->
<ShiftListDay <ShiftListDay
:day="day" :day="day"
:approved="getDayApproval(day) || timesheet.is_approved"
class="col" class="col"
@delete-unsaved-shift="deleteUnsavedShift(timesheet_index, day_index)" @delete-unsaved-shift="deleteUnsavedShift(timesheet_index, day_index)"
/> />
</div> </div>
<div class="col-auto self-stretch"> <div class="col-auto self-stretch">
<q-icon <q-icon
v-if="getDayApproval(day)" v-if="(getDayApproval(day) || timesheet.is_approved)"
name="verified" name="verified"
color="white" color="white"
size="xl" size="xl"
@ -160,6 +174,7 @@
</div> </div>
</div> </div>
</div> </div>
</transition-group>
</div> </div>
</div> </div>
</template> </template>

View File

@ -36,7 +36,7 @@
> >
<q-card class="q-pa-xl rounded-200 bg-white frosted-glass"> <q-card class="q-pa-xl rounded-200 bg-white frosted-glass">
<q-spinner-radio <q-spinner-radio
color="primary" color="accent"
size="20vh" size="20vh"
/> />
</q-card> </q-card>

View File

@ -1,28 +1,28 @@
/* eslint-disable */ /* eslint-disable */
import { normalizeObject } from "src/utils/normalize-object";
import { useExpensesStore } from "src/stores/expense-store"; import { useExpensesStore } from "src/stores/expense-store";
import { expense_validation_schema } from "src/modules/timesheets/models/expense-validation.models"; import { useTimesheetStore } from "src/stores/timesheet-store";
import type { Expense } from "src/modules/timesheets/models/expense.models"; import type { Expense } from "src/modules/timesheets/models/expense.models";
export const useExpensesApi = () => { export const useExpensesApi = () => {
const expenses_store = useExpensesStore(); const expenses_store = useExpensesStore();
const timesheet_store = useTimesheetStore();
const createExpenseByEmployeeEmail = async (employee_email: string, date: string): Promise<void> => { const upsertExpense = async (expense: Expense): Promise<void> => {
// await expenses_store.upsertOrDeleteExpensesByEmployeeEmail(employee_email, date, upsert_expense); const success = await expenses_store.upsertExpense(expense);
}; if (success) {
timesheet_store.getTimesheetsByEmployeeEmail();
const updateExpenseByEmployeeEmail = async (employee_email: string, date: string): Promise<void> => { }
// await expenses_store.upsertOrDeleteExpensesByEmployeeEmail(employee_email, date, upsert_expense);
}; };
const deleteExpenseById = async (expense_id: number): Promise<void> => { const deleteExpenseById = async (expense_id: number): Promise<void> => {
await expenses_store.deleteExpenseById(expense_id); const success = await expenses_store.deleteExpenseById(expense_id);
if (success) {
timesheet_store.getTimesheetsByEmployeeEmail();
}
}; };
return { return {
createExpenseByEmployeeEmail, upsertExpense,
updateExpenseByEmployeeEmail,
deleteExpenseById, deleteExpenseById,
}; };
}; };

View File

@ -1,78 +1,78 @@
import { type Expense, EXPENSE_TYPE, type ExpenseType } from "src/modules/timesheets/models/expense.models"; // import { type Expense, EXPENSE_TYPE, type ExpenseType } from "src/modules/timesheets/models/expense.models";
import type { Normalizer } from "src/utils/normalize-object"; // import type { Normalizer } from "src/utils/normalize-object";
export interface ApiErrorPayload { // export interface ApiErrorPayload {
status_code: number; // status_code: number;
error_code?: string; // error_code?: string;
message?: string; // message?: string;
context?: Record<string, unknown>; // context?: Record<string, unknown>;
}; // };
export abstract class ApiError extends Error { // export abstract class ApiError extends Error {
status_code: number; // status_code: number;
error_code?: string; // error_code?: string;
context?: Record<string, unknown>; // context?: Record<string, unknown>;
constructor(payload: ApiErrorPayload, defaultMessage: string) { // constructor(payload: ApiErrorPayload, defaultMessage: string) {
super(payload.message || defaultMessage); // super(payload.message || defaultMessage);
this.status_code = payload.status_code; // this.status_code = payload.status_code;
this.error_code = payload.error_code ?? "unknown"; // this.error_code = payload.error_code ?? "unknown";
this.context = payload.context ?? {'unknown': 'unknown error has occured', }; // this.context = payload.context ?? {'unknown': 'unknown error has occured', };
} // }
}; // };
export class GenericApiError extends ApiError { // export class GenericApiError extends ApiError {
constructor(payload: ApiErrorPayload) { // constructor(payload: ApiErrorPayload) {
super(payload, 'Encountered an error processing request'); // super(payload, 'Encountered an error processing request');
this.name = 'GenericApiError'; // this.name = 'GenericApiError';
} // }
}; // };
export class ExpensesValidationError extends ApiError { // export class ExpensesValidationError extends ApiError {
constructor(payload: ApiErrorPayload) { // constructor(payload: ApiErrorPayload) {
super(payload, 'Invalid expense payload'); // super(payload, 'Invalid expense payload');
this.name = 'ExpensesValidationError'; // this.name = 'ExpensesValidationError';
} // }
}; // };
export class ExpensesApiError extends ApiError { // export class ExpensesApiError extends ApiError {
constructor(payload: ApiErrorPayload) { // constructor(payload: ApiErrorPayload) {
super(payload, 'Request failed'); // super(payload, 'Request failed');
this.name = 'ExpensesApiError'; // this.name = 'ExpensesApiError';
} // }
}; // };
export const expense_validation_schema: Normalizer<Expense> = { // export const expense_validation_schema: Normalizer<Expense> = {
id: v => typeof v === 'number' ? v : -1, // id: v => typeof v === 'number' ? v : -1,
date: v => typeof v === 'string' ? v.trim() : '1970-01-01', // date: v => typeof v === 'string' ? v.trim() : '1970-01-01',
type: v => EXPENSE_TYPE.includes(v as ExpenseType) ? v as ExpenseType : "EXPENSES", // type: v => EXPENSE_TYPE.includes(v as ExpenseType) ? v as ExpenseType : "EXPENSES",
amount: v => typeof v === "number" ? v : -1, // amount: v => typeof v === "number" ? v : -1,
mileage: v => typeof v === "number" ? v : undefined, // mileage: v => typeof v === "number" ? v : undefined,
comment: v => typeof v === 'string' ? v.trim() : '', // comment: v => typeof v === 'string' ? v.trim() : '',
supervisor_comment: v => typeof v === 'string' ? v.trim() : '', // supervisor_comment: v => typeof v === 'string' ? v.trim() : '',
is_approved: v => !!v, // is_approved: v => !!v,
}; // };
export function toExpensesError(err: unknown): ExpensesValidationError | ExpensesApiError { // export function toExpensesError(err: unknown): ExpensesValidationError | ExpensesApiError {
if (err instanceof ExpensesValidationError || err instanceof ExpensesApiError) { // if (err instanceof ExpensesValidationError || err instanceof ExpensesApiError) {
return err; // return err;
} // }
if (typeof err === 'object' && err !== null && 'status_code' in err) { // if (typeof err === 'object' && err !== null && 'status_code' in err) {
const payload = err as ApiErrorPayload; // const payload = err as ApiErrorPayload;
// Don't know how to differentiate both types of errors, can be updated here // // Don't know how to differentiate both types of errors, can be updated here
if (payload.error_code?.startsWith('API_')) { // if (payload.error_code?.startsWith('API_')) {
return new ExpensesApiError(payload); // return new ExpensesApiError(payload);
} // }
return new ExpensesValidationError(payload); // return new ExpensesValidationError(payload);
} // }
// Fallback with ValidationError as default // // Fallback with ValidationError as default
return new ExpensesValidationError({ // return new ExpensesValidationError({
status_code: 500, // status_code: 500,
message: err instanceof Error ? err.message : 'Unknown error', // message: err instanceof Error ? err.message : 'Unknown error',
context: { original: err } // context: { original: err }
}); // });
} // }

View File

@ -6,6 +6,7 @@ export const TYPES_WITH_AMOUNT_ONLY: Readonly<ExpenseType[]> = ['PER_DIEM', 'EXP
export class Expense { export class Expense {
id: number; id: number;
timesheet_id: number;
date: string; //YYYY-MM-DD date: string; //YYYY-MM-DD
type: ExpenseType; type: ExpenseType;
amount: number; amount: number;
@ -16,6 +17,7 @@ export class Expense {
constructor(date: string) { constructor(date: string) {
this.id = -1; this.id = -1;
this.timesheet_id = -1;
this.date = date; this.date = date;
this.type = 'EXPENSES'; this.type = 'EXPENSES';
this.amount = 0; this.amount = 0;

View File

@ -2,17 +2,17 @@ import { api } from "src/boot/axios";
import type { Expense } from "src/modules/timesheets/models/expense.models"; import type { Expense } from "src/modules/timesheets/models/expense.models";
export const ExpenseService = { export const ExpenseService = {
createExpense: async (expense: Expense) => { createExpense: async (expense: Expense): Promise<{success: boolean, data: Expense, error?: unknown}> => {
const response = await api.post('expense/create', expense); const response = await api.post('expense/create', expense);
return response.data; return response.data;
}, },
updateExpenseById: async (expense: Expense) => { updateExpense: async (expense: Expense): Promise<{success: boolean, data: Expense, error?: unknown}> => {
const response = await api.patch(`expense/update`, expense); 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}> => { deleteExpenseById: async (expense_id: number): Promise<{success: boolean, data: number, error?: unknown}> => {
const response = await api.delete(`expense/delete/${expense_id}`); const response = await api.delete(`expense/delete/${expense_id}`);
return response.data; return response.data;
} }

View File

@ -24,3 +24,20 @@ export const useExpenseRules = (t: (_key: string) => string) => {
commentRequired, commentRequired,
}; };
}; };
export const convertToMonetaryAmount = (amount: number | string): number => {
if (typeof amount === 'number') return Number(amount.toFixed(2));
if (typeof amount === 'string') {
try {
const single_decimal_amount = amount.replace(/\.(?=.*\.)/g, '');
const numbers_only_decimal = single_decimal_amount.replace(/[^0-9.]/g, '');
return Number(numbers_only_decimal);
} catch(error) {
console.error(error);
}
}
return 0;
};

View File

@ -4,10 +4,10 @@
<template> <template>
<q-layout view="hHh lpR fFf"> <q-layout view="hHh lpR fFf">
<q-page-container class="bg-dark"> <q-page-container class="bg-secondary">
<q-img src="src/assets/village.png" fit="cover" position="50% 100%" class="absolute-full" /> <q-page class="column">
<q-page class="flex flex-center"> <q-img src="src/assets/village.png" fit="contain" class="col absolute-bottom-right" style="opacity: 50%;" />
<transition appear slow enter-active-class="animated zoomIn" leave-active-class="animated zoomOut"> <transition appear slow enter-active-class="animated zoomIn" leave-active-class="animated zoomOut" class="col absolute-center">
<LoginConnectionPanel /> <LoginConnectionPanel />
</transition> </transition>
</q-page> </q-page>

View File

@ -25,24 +25,28 @@ export const useExpensesStore = defineStore('expenses', () => {
const close = () => { const close = () => {
is_open.value = false; is_open.value = false;
is_hiding_create_form.value = false;
}; };
const upsertExpensesById = async (expense_id: number, expense: Expense): Promise<void> => { const upsertExpense = async (expense: Expense): Promise<boolean> => {
try { try {
if (expense_id < 0) { if (expense.id < 0) {
const data = await ExpenseService.createExpense(expense); const data = await ExpenseService.createExpense(expense);
return data; return data.success;
} }
// TODO: Save response data into proper ref const data = await ExpenseService.updateExpense(expense);
return data.success;
} catch (err) { } catch (err) {
// setErrorFrom(err); // setErrorFrom(err);
console.error(err); console.error(err);
return false;
} }
}; };
const deleteExpenseById = async (expense_id: number): Promise<boolean> => { const deleteExpenseById = async (expense_id: number): Promise<boolean> => {
const data = await ExpenseService.deleteExpenseById(expense_id); const data = await ExpenseService.deleteExpenseById(expense_id);
return data.ok; console.log('data received from expense deletion: ', data);
return data.success;
} }
return { return {
@ -53,7 +57,7 @@ export const useExpensesStore = defineStore('expenses', () => {
current_expense, current_expense,
initial_expense, initial_expense,
open, open,
upsertExpensesById, upsertExpense,
deleteExpenseById, deleteExpenseById,
close, close,
}; };