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

Reviewed-on: Targo/targo_frontend#27
This commit is contained in:
Nicolas 2025-11-24 09:19:37 -05:00
commit 3669f65fe4
9 changed files with 206 additions and 137 deletions

View File

@ -1,4 +1,7 @@
<script setup lang="ts"> <script
setup
lang="ts"
>
import type { EmployeeProfile } from 'src/modules/employee-list/models/employee-profile.models'; import type { EmployeeProfile } from 'src/modules/employee-list/models/employee-profile.models';
// const getEmployeeAvatar = (first_name: string, last_name: string) => { // const getEmployeeAvatar = (first_name: string, last_name: string) => {
@ -6,8 +9,9 @@
// return first_name.charAt(0) + last_name.charAt(0); // return first_name.charAt(0) + last_name.charAt(0);
// }; // };
const { row } = defineProps<{ const { row, index = 0 } = defineProps<{
row: EmployeeProfile row: EmployeeProfile
index?: number
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
onProfileClick: [email: string] onProfileClick: [email: string]
@ -15,15 +19,21 @@
</script> </script>
<template> <template>
<transition
appear
enter-active-class="animated fadeInUp slow"
mode="out-in"
>
<q-card <q-card
v-ripple v-ripple
class="column col-xs-6 col-sm-4 col-md-3 col-lg-2 no-wrap rounded-15 cursor-pointer q-ma-sm" class="column col-xs-6 col-sm-4 col-md-3 col-lg-2 no-wrap rounded-15 cursor-pointer q-ma-sm"
style="max-width: 230px;" style="max-width: 230px;"
:style="`animation-delay: ${index / 25}s;`"
@click="emit('onProfileClick', row.email)" @click="emit('onProfileClick', row.email)"
> >
<q-card-section class="col-6 text-center"> <q-card-section class="col-6 text-center">
<q-avatar <q-avatar
color="primary" :color="row.last_work_day === undefined ? 'accent' : 'negative'"
size="8em" size="8em"
class="shadow-3 q-mb-md" class="shadow-3 q-mb-md"
> >
@ -39,13 +49,17 @@
class="col-grow text-center text-h6 text-weight-medium text-uppercase q-pb-none" class="col-grow text-center text-h6 text-weight-medium text-uppercase q-pb-none"
style="line-height: 0.8em;" style="line-height: 0.8em;"
> >
<div class="ellipsis text-primary"> {{ row.first_name }} {{ row.last_name }} </div> <div class="ellipsis" :class="row.last_work_day === undefined ? 'text-accent' : 'text-negative'"> {{ row.first_name }} {{ row.last_name }} </div>
<q-separator color="primary" class="q-mx-sm q-mt-xs" /> <q-separator
color="accent"
class="q-mx-sm q-mt-xs"
/>
<div class=" ellipsis-2-lines text-caption"> {{ row.job_title }} </div> <div class=" ellipsis-2-lines text-caption"> {{ row.job_title }} </div>
</q-card-section> </q-card-section>
<q-card-section class="bg-primary text-white text-caption text-center q-py-none col-2 content-center "> <q-card-section class="bg-primary text-white text-caption text-center q-py-none col-2 content-center">
<div> {{ row.email }} </div> <div> {{ row.email }} </div>
</q-card-section> </q-card-section>
</q-card> </q-card>
</transition>
</template> </template>

View File

@ -1,31 +1,21 @@
<script setup lang="ts"> <script
import { computed, onMounted, ref } from 'vue'; setup
lang="ts"
>
import { onMounted, ref } from 'vue';
import { useEmployeeListApi } from 'src/modules/employee-list/composables/use-employee-api'; import { useEmployeeListApi } from 'src/modules/employee-list/composables/use-employee-api';
import { useEmployeeStore } from 'src/stores/employee-store'; import { useEmployeeStore } from 'src/stores/employee-store';
import { useI18n } from 'vue-i18n';
import EmployeeListTableItem from 'src/modules/employee-list/components/employee-list-table-item.vue'; import EmployeeListTableItem from 'src/modules/employee-list/components/employee-list-table-item.vue';
import type { EmployeeProfile } from 'src/modules/employee-list/models/employee-profile.models'; import { employee_list_columns } from 'src/modules/employee-list/models/employee-profile.models';
import type { QTableColumn } from 'quasar';
const employee_list_api = useEmployeeListApi(); const employee_list_api = useEmployeeListApi();
const employee_store = useEmployeeStore(); const employee_store = useEmployeeStore();
const is_loading_list = ref<boolean>(true); const is_loading_list = ref<boolean>(true);
const { t } = useI18n();
const filter = ref(""); const filter = ref("");
const is_grid_mode = ref(true); const is_grid_mode = ref(true);
const pagination = ref({ rowsPerPage: 0 });
const employee_list_columns = computed((): QTableColumn<EmployeeProfile>[] => [ onMounted(async () => {
{name: 'first_name', label: t('employee_list.table.first_name'), field: 'first_name', align: 'left'},
{name: 'last_name', label: t('employee_list.table.last_name'), field: 'last_name', align: 'left'},
{name: 'email', label: t('employee_list.table.email'), field: 'email', align: 'left'},
{name: 'supervisor_full_name', label: t('employee_list.table.supervisor'), field: 'supervisor_full_name', align: 'left'},
{name: 'company_name', label: t('employee_list.table.company'), field: 'company_name', align: 'left'},
{name: 'job_title', label: t('employee_list.table.role'), field: 'job_title', align: 'left'},
]);
onMounted( async () => {
is_loading_list.value = true; is_loading_list.value = true;
await employee_list_api.getEmployeeList(); await employee_list_api.getEmployeeList();
is_loading_list.value = false; is_loading_list.value = false;
@ -41,18 +31,17 @@
virtual-scroll virtual-scroll
title=" " title=" "
card-style="max-height: 70vh;" card-style="max-height: 70vh;"
:rows="employee_store.employeeList" :rows="employee_store.employee_list"
:columns="employee_list_columns" :columns="employee_list_columns"
row-key="name" row-key="name"
v-model:pagination="pagination"
:rows-per-page-options="[0]" :rows-per-page-options="[0]"
:filter="filter" :filter="filter"
class="q-pa-md bg-transparent" class="q-pa-md bg-transparent"
:class="is_grid_mode ? '': 'sticky-header-table'" :class="is_grid_mode ? '' : 'sticky-header-table'"
:style="$q.screen.lt.md ? '' : 'width: 80vw;'" :style="$q.screen.lt.md ? '' : 'width: 80vw;'"
:table-class="$q.dark.isActive ? 'q-px-md q-py-none q-mx-md rounded-10 bg-dark' : 'q-px-md q-py-none q-mx-md rounded-10 bg-white'" :table-class="$q.dark.isActive ? 'q-px-md q-py-none q-mx-md rounded-10 bg-dark' : 'q-px-md q-py-none q-mx-md rounded-10 bg-white'"
color="primary" color="accent"
table-header-class="text-primary text-uppercase" table-header-class="text-accent text-uppercase"
card-container-class="justify-center" card-container-class="justify-center"
:grid="is_grid_mode" :grid="is_grid_mode"
:loading="is_loading_list" :loading="is_loading_list"
@ -61,15 +50,35 @@
:loading-label="$t('shared.label.loading')" :loading-label="$t('shared.label.loading')"
@row-click="() => console.log('click!')" @row-click="() => console.log('click!')"
> >
<template #header="props">
<q-tr
:props="props"
class="bg-accent"
>
<q-th
v-for="col in props.cols"
:key="col.name"
:props="props"
>
<span class="text-uppercase text-weight-bolder text-white">
{{ $t(col.label) }}
</span>
</q-th>
</q-tr>
</template>
<template v-slot:item="props"> <template v-slot:item="props">
<EmployeeListTableItem :row="props.row"/> <EmployeeListTableItem
:row="props.row"
:index="props.rowIndex"
/>
</template> </template>
<template v-slot:top> <template v-slot:top>
<div class="row full-width q-mb-sm"> <div class="row full-width q-mb-sm">
<q-btn <q-btn
push push
color="primary" color="accent"
icon="person_add" icon="person_add"
:label="$t('shared.label.add')" :label="$t('shared.label.add')"
class="text-uppercase" class="text-uppercase"
@ -80,13 +89,14 @@
<q-btn-toggle <q-btn-toggle
v-model="is_grid_mode" v-model="is_grid_mode"
push push
rounded
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 },
{icon: 'view_list', value: false}, { icon: 'view_list', value: false },
]" ]"
/> />
<q-input <q-input
@ -94,15 +104,15 @@
outlined outlined
dense dense
rounded rounded
color="primary" color="accent"
bg-color="white" bg-color="white"
label-color="primary" label-color="accent"
:label="$t('shared.label.search')" :label="$t('shared.label.search')"
> >
<template v-slot:append> <template v-slot:append>
<q-icon <q-icon
name="search" name="search"
color="primary" color="accent"
/> />
</template> </template>
</q-input> </q-input>
@ -111,7 +121,7 @@
<!-- Template for custome failed-to-load state --> <!-- Template for custome failed-to-load state -->
<template v-slot:no-data="{ message, filter }"> <template v-slot: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">
<span class="text-h6 q-mt-xl"> <span class="text-h6 q-mt-xl">
{{ message }} {{ message }}
</span> </span>

View File

@ -1,3 +1,5 @@
import type { QTableColumn } from "quasar";
export interface EmployeeProfile { export interface EmployeeProfile {
first_name: string; first_name: string;
last_name: string; last_name: string;
@ -25,3 +27,42 @@ export const default_employee_profile: EmployeeProfile = {
residence: '', residence: '',
birth_date: '', birth_date: '',
} }
export const employee_list_columns: QTableColumn<EmployeeProfile>[] = [
{
name: 'first_name',
label: 'employee_list.table.first_name',
field: 'first_name',
align: 'left'
},
{
name: 'last_name',
label: 'employee_list.table.last_name',
field: 'last_name',
align: 'left'
},
{
name: 'email',
label: 'employee_list.table.email',
field: 'email',
align: 'left'
},
{
name: 'supervisor_full_name',
label: 'employee_list.table.supervisor',
field: 'supervisor_full_name',
align: 'left'
},
{
name: 'company_name',
label: 'employee_list.table.company',
field: 'company_name',
align: 'left'
},
{
name: 'job_title',
label: 'employee_list.table.role',
field: 'job_title',
align: 'left'
},
];

View File

@ -3,8 +3,8 @@ import type { EmployeeProfile } from 'src/modules/employee-list/models/employee-
export const EmployeeListService = { export const EmployeeListService = {
getEmployeeList: async (): Promise<EmployeeProfile[]> => { getEmployeeList: async (): Promise<EmployeeProfile[]> => {
const response = await api.get<EmployeeProfile[]>('/employees/employee-list') const response = await api.get<{success: boolean, data: EmployeeProfile[], error?: string }>('/employees/employee-list')
return response.data; return response.data.data;
}, },
getEmployeeDetails: async (email: string): Promise<EmployeeProfile> => { getEmployeeDetails: async (email: string): Promise<EmployeeProfile> => {

View File

@ -2,14 +2,12 @@
setup setup
lang="ts" lang="ts"
> >
/* eslint-disable */
import { onMounted, ref } from 'vue'; import { onMounted, ref } from 'vue';
import { Bar } from 'vue-chartjs'; import { Bar } from 'vue-chartjs';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useQuasar, colors } from 'quasar'; import { useQuasar, colors } from 'quasar';
import { useTimesheetStore } from 'src/stores/timesheet-store'; import { useTimesheetStore } from 'src/stores/timesheet-store';
import { Chart as ChartJS, Title, Tooltip, Legend, BarElement, CategoryScale, LinearScale, TimeScale, type ChartData, type ChartDataset } from 'chart.js'; import { Chart as ChartJS, Title, Tooltip, Legend, BarElement, CategoryScale, LinearScale, TimeScale, type ChartDataset } from 'chart.js';
const { t } = useI18n(); const { t } = useI18n();
const $q = useQuasar(); const $q = useQuasar();
@ -27,6 +25,7 @@
const expenses_dataset = ref<ChartDataset<'bar'>[]>([]); const expenses_dataset = ref<ChartDataset<'bar'>[]>([]);
onMounted(() => { onMounted(() => {
setTimeout(() => {
expenses_dataset.value = [ expenses_dataset.value = [
{ {
label: t('timesheet_approvals.table.expenses'), label: t('timesheet_approvals.table.expenses'),
@ -39,6 +38,7 @@
backgroundColor: colors.getPaletteColor('info'), backgroundColor: colors.getPaletteColor('info'),
} }
] ]
}, 100)
}); });
</script> </script>
@ -55,7 +55,6 @@
:options="({ :options="({
indexAxis: $q.screen.lt.md ? 'y' : 'x', indexAxis: $q.screen.lt.md ? 'y' : 'x',
plugins: { plugins: {
title: { title: {
display: true, display: true,
text: t('timesheet_approvals.chart.expenses_title'), text: t('timesheet_approvals.chart.expenses_title'),

View File

@ -2,11 +2,11 @@
setup setup
lang="ts" lang="ts"
> >
import { ref, computed, onMounted } from 'vue'; import { ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { colors, useQuasar } from 'quasar'; import { colors, useQuasar } from 'quasar';
import { Bar } from 'vue-chartjs'; import { Bar } from 'vue-chartjs';
import { Chart as ChartJS, Title, Tooltip, Legend, BarElement, CategoryScale, LinearScale, type ChartData, type ChartDataset } from 'chart.js'; import { Chart as ChartJS, Title, Tooltip, Legend, BarElement, CategoryScale, LinearScale, TimeScale, type ChartDataset } from 'chart.js';
import { useTimesheetStore } from 'src/stores/timesheet-store'; import { useTimesheetStore } from 'src/stores/timesheet-store';
import type { TotalHours } from 'src/modules/timesheets/models/timesheet.models'; import type { TotalHours } from 'src/modules/timesheets/models/timesheet.models';
@ -19,14 +19,14 @@
const { t } = useI18n(); const { t } = useI18n();
const $q = useQuasar(); const $q = useQuasar();
ChartJS.register(Title, Tooltip, Legend, BarElement, CategoryScale, LinearScale); ChartJS.register(Title, Tooltip, Legend, BarElement, CategoryScale, LinearScale, TimeScale);
ChartJS.defaults.font.family = '"Roboto", sans-serif'; ChartJS.defaults.font.family = '"Roboto", sans-serif';
// ChartJS.defaults.maintainAspectRatio = false; ChartJS.defaults.maintainAspectRatio = false;
ChartJS.defaults.color = $q.dark.isActive ? '#F5F5F5' : '#616161'; ChartJS.defaults.color = $q.dark.isActive ? '#F5F5F5' : '#616161';
const timesheet_store = useTimesheetStore(); const timesheet_store = useTimesheetStore();
const all_days = computed(() => timesheet_store.timesheets.flatMap(week => week.days)); const all_days = timesheet_store.timesheets.flatMap(week => week.days);
const datasetConfig: ChartConfigHoursWorked[] = [ const datasetConfig: ChartConfigHoursWorked[] = [
{ {
@ -42,36 +42,38 @@
{ {
key: 'emergency', key: 'emergency',
label: t('shared.shift_type.emergency'), label: t('shared.shift_type.emergency'),
color: getComputedStyle(document.body).getPropertyValue('--q-warning').trim(), color: colors.getPaletteColor('warning'),
}, },
{ {
key: 'overtime', key: 'overtime',
label: t('shared.shift_type.overtime'), label: t('shared.shift_type.overtime'),
color: getComputedStyle(document.body).getPropertyValue('--q-negative').trim(), color: colors.getPaletteColor('negative'),
}, },
]; ];
const hours_worked_labels = ref<string[]>(all_days.value.map(day => day.date.slice(-5,))); const hours_worked_labels = ref<string[]>(all_days.map(day => day.date.slice(-5,)));
const hours_worked_dataset = ref<ChartDataset<'bar'>[]>([]); const hours_worked_dataset = ref<ChartDataset<'bar'>[]>([]);
onMounted(() => { onMounted(() => {
setTimeout(() => {
hours_worked_dataset.value = datasetConfig.map(cfg => ({ hours_worked_dataset.value = datasetConfig.map(cfg => ({
label: cfg.label, label: cfg.label,
data: all_days.value.map(day => day.daily_hours[cfg.key]), data: all_days.map(day => day.daily_hours[cfg.key]),
backgroundColor: cfg.color, backgroundColor: cfg.color,
})); }));
}, 100);
}); });
</script> </script>
<template> <template>
<div <div
class="bg-dark rounded-10 q-pa-sm" class="bg-dark rounded-10 q-pa-sm"
:style="`min-height: ${$q.screen.lt.md ? '450px;' : '200px'}`" :style="`min-height: ${$q.screen.lt.md ? '350px;' : '200px'}`"
> >
<Bar <Bar
:data="{ :data="{
labels: hours_worked_labels,
datasets: hours_worked_dataset, datasets: hours_worked_dataset,
labels: hours_worked_labels,
}" }"
:options="({ :options="({
indexAxis: $q.screen.lt.md ? 'y' : 'x', indexAxis: $q.screen.lt.md ? 'y' : 'x',

View File

@ -29,6 +29,7 @@
const shift_type_totals = ref<ChartDataset<'doughnut'>[]>([]); const shift_type_totals = ref<ChartDataset<'doughnut'>[]>([]);
onMounted(() => { onMounted(() => {
setTimeout(() => {
shift_type_totals.value = [{ shift_type_totals.value = [{
data: [ data: [
timesheet_store.current_pay_period_overview!.regular_hours, timesheet_store.current_pay_period_overview!.regular_hours,
@ -43,6 +44,8 @@
colors.getPaletteColor('negative'), // Overtime colors.getPaletteColor('negative'), // Overtime
] ]
}] }]
}, 100);
}); });
</script> </script>

View File

@ -4,7 +4,7 @@ import type { PayPeriodOverviewResponse } from "src/modules/timesheet-approval/m
export const timesheetApprovalService = { export const timesheetApprovalService = {
getPayPeriodOverviews: async (year: number, period_number: number): Promise<PayPeriodOverviewResponse> => { getPayPeriodOverviews: async (year: number, period_number: number): Promise<PayPeriodOverviewResponse> => {
const response = await api.get(`pay-periods/crew/${year}/${period_number}`); const response = await api.get(`pay-periods/overview/${year}/${period_number}`);
return response.data; return response.data;
}, },

View File

@ -5,7 +5,7 @@ import { default_employee_profile, type EmployeeProfile } from "src/modules/empl
export const useEmployeeStore = defineStore('employee', () => { export const useEmployeeStore = defineStore('employee', () => {
const employee = ref<EmployeeProfile>( default_employee_profile ); const employee = ref<EmployeeProfile>( default_employee_profile );
const employeeList = ref<EmployeeProfile[]>([]); const employee_list = ref<EmployeeProfile[]>([]);
const isShowingEmployeeAddModifyWindow = ref<boolean>(false); const isShowingEmployeeAddModifyWindow = ref<boolean>(false);
const isLoadingEmployeeProfile = ref(false); const isLoadingEmployeeProfile = ref(false);
const isLoadingEmployeeList = ref(false); const isLoadingEmployeeList = ref(false);
@ -14,7 +14,7 @@ export const useEmployeeStore = defineStore('employee', () => {
isLoadingEmployeeList.value = true; isLoadingEmployeeList.value = true;
try { try {
const response = await EmployeeListService.getEmployeeList(); const response = await EmployeeListService.getEmployeeList();
employeeList.value = response; employee_list.value = response;
} catch (error) { } catch (error) {
console.error("Ran into an error fetching employee list: ", error); console.error("Ran into an error fetching employee list: ", error);
//TODO: trigger an alert window with an error message here! //TODO: trigger an alert window with an error message here!
@ -35,6 +35,6 @@ export const useEmployeeStore = defineStore('employee', () => {
isLoadingEmployeeProfile.value = false; isLoadingEmployeeProfile.value = false;
}; };
return { employee, employeeList, isShowingEmployeeAddModifyWindow, isLoadingEmployeeList, isLoadingEmployeeProfile, getEmployeeList, getEmployeeDetails }; return { employee, employee_list, isShowingEmployeeAddModifyWindow, isLoadingEmployeeList, isLoadingEmployeeProfile, getEmployeeList, getEmployeeDetails };
}); });