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';
// const getEmployeeAvatar = (first_name: string, last_name: string) => {
@ -6,8 +9,9 @@
// return first_name.charAt(0) + last_name.charAt(0);
// };
const { row } = defineProps<{
const { row, index = 0 } = defineProps<{
row: EmployeeProfile
index?: number
}>()
const emit = defineEmits<{
onProfileClick: [email: string]
@ -15,15 +19,21 @@
</script>
<template>
<transition
appear
enter-active-class="animated fadeInUp slow"
mode="out-in"
>
<q-card
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"
style="max-width: 230px;"
:style="`animation-delay: ${index / 25}s;`"
@click="emit('onProfileClick', row.email)"
>
<q-card-section class="col-6 text-center">
<q-avatar
color="primary"
:color="row.last_work_day === undefined ? 'accent' : 'negative'"
size="8em"
class="shadow-3 q-mb-md"
>
@ -39,8 +49,11 @@
class="col-grow text-center text-h6 text-weight-medium text-uppercase q-pb-none"
style="line-height: 0.8em;"
>
<div class="ellipsis text-primary"> {{ row.first_name }} {{ row.last_name }} </div>
<q-separator color="primary" class="q-mx-sm q-mt-xs" />
<div class="ellipsis" :class="row.last_work_day === undefined ? 'text-accent' : 'text-negative'"> {{ row.first_name }} {{ row.last_name }} </div>
<q-separator
color="accent"
class="q-mx-sm q-mt-xs"
/>
<div class=" ellipsis-2-lines text-caption"> {{ row.job_title }} </div>
</q-card-section>
@ -48,4 +61,5 @@
<div> {{ row.email }} </div>
</q-card-section>
</q-card>
</transition>
</template>

View File

@ -1,29 +1,19 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
<script
setup
lang="ts"
>
import { onMounted, ref } from 'vue';
import { useEmployeeListApi } from 'src/modules/employee-list/composables/use-employee-api';
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 type { EmployeeProfile } from 'src/modules/employee-list/models/employee-profile.models';
import type { QTableColumn } from 'quasar';
import { employee_list_columns } from 'src/modules/employee-list/models/employee-profile.models';
const employee_list_api = useEmployeeListApi();
const employee_store = useEmployeeStore();
const is_loading_list = ref<boolean>(true);
const { t } = useI18n();
const filter = ref("");
const is_grid_mode = ref(true);
const pagination = ref({ rowsPerPage: 0 });
const employee_list_columns = computed((): QTableColumn<EmployeeProfile>[] => [
{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;
@ -41,18 +31,17 @@
virtual-scroll
title=" "
card-style="max-height: 70vh;"
:rows="employee_store.employeeList"
:rows="employee_store.employee_list"
:columns="employee_list_columns"
row-key="name"
v-model:pagination="pagination"
:rows-per-page-options="[0]"
:filter="filter"
class="q-pa-md bg-transparent"
:class="is_grid_mode ? '' : 'sticky-header-table'"
: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'"
color="primary"
table-header-class="text-primary text-uppercase"
color="accent"
table-header-class="text-accent text-uppercase"
card-container-class="justify-center"
:grid="is_grid_mode"
:loading="is_loading_list"
@ -61,15 +50,35 @@
:loading-label="$t('shared.label.loading')"
@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">
<EmployeeListTableItem :row="props.row"/>
<EmployeeListTableItem
:row="props.row"
:index="props.rowIndex"
/>
</template>
<template v-slot:top>
<div class="row full-width q-mb-sm">
<q-btn
push
color="primary"
color="accent"
icon="person_add"
:label="$t('shared.label.add')"
class="text-uppercase"
@ -80,9 +89,10 @@
<q-btn-toggle
v-model="is_grid_mode"
push
rounded
color="white"
text-color="primary"
toggle-color="primary"
text-color="accent"
toggle-color="accent"
class="q-mr-md"
:options="[
{ icon: 'grid_view', value: true },
@ -94,15 +104,15 @@
outlined
dense
rounded
color="primary"
color="accent"
bg-color="white"
label-color="primary"
label-color="accent"
:label="$t('shared.label.search')"
>
<template v-slot:append>
<q-icon
name="search"
color="primary"
color="accent"
/>
</template>
</q-input>
@ -111,7 +121,7 @@
<!-- Template for custome failed-to-load state -->
<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">
{{ message }}
</span>

View File

@ -1,3 +1,5 @@
import type { QTableColumn } from "quasar";
export interface EmployeeProfile {
first_name: string;
last_name: string;
@ -25,3 +27,42 @@ export const default_employee_profile: EmployeeProfile = {
residence: '',
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 = {
getEmployeeList: async (): Promise<EmployeeProfile[]> => {
const response = await api.get<EmployeeProfile[]>('/employees/employee-list')
return response.data;
const response = await api.get<{success: boolean, data: EmployeeProfile[], error?: string }>('/employees/employee-list')
return response.data.data;
},
getEmployeeDetails: async (email: string): Promise<EmployeeProfile> => {

View File

@ -2,14 +2,12 @@
setup
lang="ts"
>
/* eslint-disable */
import { onMounted, ref } from 'vue';
import { Bar } from 'vue-chartjs';
import { useI18n } from 'vue-i18n';
import { useQuasar, colors } from 'quasar';
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 $q = useQuasar();
@ -27,6 +25,7 @@
const expenses_dataset = ref<ChartDataset<'bar'>[]>([]);
onMounted(() => {
setTimeout(() => {
expenses_dataset.value = [
{
label: t('timesheet_approvals.table.expenses'),
@ -39,6 +38,7 @@
backgroundColor: colors.getPaletteColor('info'),
}
]
}, 100)
});
</script>
@ -55,7 +55,6 @@
:options="({
indexAxis: $q.screen.lt.md ? 'y' : 'x',
plugins: {
title: {
display: true,
text: t('timesheet_approvals.chart.expenses_title'),

View File

@ -2,11 +2,11 @@
setup
lang="ts"
>
import { ref, computed, onMounted } from 'vue';
import { ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { colors, useQuasar } from 'quasar';
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 type { TotalHours } from 'src/modules/timesheets/models/timesheet.models';
@ -19,14 +19,14 @@
const { t } = useI18n();
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.maintainAspectRatio = false;
ChartJS.defaults.maintainAspectRatio = false;
ChartJS.defaults.color = $q.dark.isActive ? '#F5F5F5' : '#616161';
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[] = [
{
@ -42,36 +42,38 @@
{
key: 'emergency',
label: t('shared.shift_type.emergency'),
color: getComputedStyle(document.body).getPropertyValue('--q-warning').trim(),
color: colors.getPaletteColor('warning'),
},
{
key: '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'>[]>([]);
onMounted(() => {
setTimeout(() => {
hours_worked_dataset.value = datasetConfig.map(cfg => ({
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,
}));
}, 100);
});
</script>
<template>
<div
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
:data="{
labels: hours_worked_labels,
datasets: hours_worked_dataset,
labels: hours_worked_labels,
}"
:options="({
indexAxis: $q.screen.lt.md ? 'y' : 'x',

View File

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

View File

@ -4,7 +4,7 @@ import type { PayPeriodOverviewResponse } from "src/modules/timesheet-approval/m
export const timesheetApprovalService = {
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;
},

View File

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