feat(schedule-preset): Add update and create functionality to schedule preset with preview

This commit is contained in:
Nic D. 2025-12-10 16:59:41 -05:00
parent f6e9415369
commit fd2146567f
18 changed files with 334 additions and 75 deletions

View File

@ -29,11 +29,12 @@ export default defineConfig((ctx) => {
// 'fontawesome-v6',
// 'eva-icons',
// 'themify',
// 'line-awesome',
'line-awesome',
'material-icons',
'material-icons-outlined',
'roboto-font',
// 'roboto-font-latin-ext', // this or either 'roboto-font', NEVER both!
'roboto-font', // optional, you are not bound to it
'material-icons', // optional, you are not bound to it
],
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#build

View File

@ -20,7 +20,7 @@ export default {
modify_employee: "Modify employee",
access_label: "access",
details_label: "details",
schedule_label: "schedules",
schedule_label: "schedule",
schedule_presets: {
preset_list_placeholder: "Select a schedule",
preset_name_placeholder: "schedule preset name",

View File

@ -20,7 +20,7 @@ export default {
modify_employee: "Modifier employé",
access_label: "accès",
details_label: "détails",
schedule_label: "horaires",
schedule_label: "horaire",
schedule_presets: {
preset_list_placeholder: "Sélectionner un horaire",
preset_name_placeholder: "nom de l'horaire",

View File

@ -93,7 +93,7 @@
>
<q-item-section avatar>
<q-icon
name="view_list"
name="groups"
color="accent"
size="lg"
/>

View File

@ -5,7 +5,7 @@
import { ref } from 'vue';
import { unwrapAndClone } from 'src/utils/unwrap-and-clone';
import { useEmployeeStore } from 'src/stores/employee-store';
import { employee_access_options, type ModuleAccessPreset, type ModuleAccessName, employee_access_presets } from 'src/modules/employee-list/models/employee-profile.models';
import { employee_access_options, type ModuleAccessPreset, type ModuleAccessName, employee_access_presets, getEmployeeAccessOptionIcon } from 'src/modules/employee-list/models/employee-profile.models';
const employee_store = useEmployeeStore();
const preset_preview = ref<ModuleAccessPreset>();
@ -20,6 +20,7 @@
employee_store.employee.user_module_access = unwrapAndClone(employee_access_presets[preset]);
}
const getPreviewBackgroundColor = (name: ModuleAccessName) => {
if (employee_access_presets[preset_preview.value!].includes(name)) {
if (!employee_store.employee.user_module_access.includes(name)) return 'bg-info text-white';
@ -141,8 +142,9 @@
class="row full-width cursor-pointer flex-center q-pa-sm rounded-5 no-wrap shadow-5"
:class="preset_preview !== undefined ? getPreviewBackgroundColor(option.value) : getBackgroundColor(option.value)"
@click="toggleInSelected(option.value)"
>
<span class="text-uppercase text-weight-bold">
>
<q-icon :name="getEmployeeAccessOptionIcon(option.value)" size="sm" class="q-mr-sm"/>
<span class="text-uppercase text-weight-bold non-selectable">
{{ $t('employee_management.module_access.' + option.value) }}
</span>
<q-space />

View File

@ -0,0 +1,59 @@
<script
setup
lang="ts"
>
import { date } from 'quasar';
import { useSchedulePresetsStore } from 'src/stores/schedule-presets.store';
const schedule_preset_store = useSchedulePresetsStore();
defineProps<{
currentPresetId: number;
}>();
</script>
<template>
<div class="row flex-center fit">
<div
v-if="currentPresetId > 0"
class="col column fit flex-center q-pa-md"
>
<div
v-for="weekday in schedule_preset_store.current_schedule_preset.weekdays"
:key="weekday.day"
class="col row justify-center q-py-xs full-width"
>
<div class="col-10 row items-center bg-dark q-px-md shadow-10 rounded-10">
<span class="col-2 text-weight-bolder text-accent text-uppercase text-overline" style="font-size: 1.3em;">{{
$t(`shared.weekday.${weekday.day.toLowerCase()}`) }}</span>
<div
v-for="shift, index in weekday.shifts.sort((a, b) => date.extractDate(a.start_time, 'HH:mm').getTime() - date.extractDate(b.start_time, 'HH:mm').getTime())"
:key="index"
class="col q-px-md q-py-xs"
>
<div class="row flex-center rounded-5" style="border: 1px solid var(--q-accent);">
<div class="col bg-accent text-white text-uppercase text-weight-bolder text-center">
{{ $t(`shared.shift_type.${shift.type.toLowerCase()}`) }}
</div>
<div class="col text-center text-bold">{{ shift.start_time }}</div>
<q-icon name="las la-chevron-right" color="accent" class="col-auto"></q-icon>
<div class="col text-center text-bold">{{ shift.end_time }}</div>
</div>
</div>
</div>
</div>
</div>
<div
v-else
class="col row justify-center"
>
<q-icon
name="las la-calendar-week"
size="20em"
color="accent"
style="opacity: 0.3;"
/>
</div>
</div>
</template>

View File

@ -4,19 +4,24 @@
>
import HorizontalSlideTransition from 'src/modules/shared/components/horizontal-slide-transition.vue';
import SchedulePresetsDialog from 'src/modules/employee-list/components/schedule_presets_dialog.vue';
import AddModifyDialogSchedulePreview from './add-modify-dialog-schedule-preview.vue';
import { onMounted, ref } from 'vue';
import { useSchedulePresetsStore } from 'src/stores/schedule-presets.store';
import { useEmployeeStore } from 'src/stores/employee-store';
import { useEmployeeListApi } from '../composables/use-employee-api';
const schedule_preset_store = useSchedulePresetsStore();
const employee_store = useEmployeeStore();
const employee_list_api = useEmployeeListApi();
const preset_options = ref<{ label: string, value: number }[]>([]);
const current_preset = ref<{ label: string | undefined, value: number }>({ label: undefined, value: -1 });
const getPresetOptions = (): { label: string, value: number }[] => {
return schedule_preset_store.schedule_presets.map(preset => { return { label: preset.name, value: preset.id } });
const options = schedule_preset_store.schedule_presets.map(preset => { return { label: preset.name, value: preset.id } });
options.push({ label: '', value: -1 });
return options;
};
onMounted(() => {
@ -35,6 +40,7 @@
v-model="current_preset"
standout="bg-accent"
dense
options-dense
rounded
color="accent"
:options="getPresetOptions()"
@ -44,11 +50,16 @@
menu-anchor="bottom middle"
menu-self="top middle"
:menu-offset="[0, 10]"
@update:modelValue="option => schedule_preset_store.setCurrentSchedulePreset(option.value)"
@update:modelValue="option => employee_list_api.setSchedulePreset(option.value)"
>
<template #selected>
<span class="text-uppercase" :style="current_preset.label === undefined ? 'opacity: 0.5;' : ''">
{{ current_preset.label === undefined ? $t('employee_management.schedule_presets.preset_list_placeholder') : current_preset.label }}
<span
class="text-uppercase text-center text-weight-bold full-width"
:style="current_preset.label === undefined ? 'opacity: 0.5;' : ''"
>
{{ current_preset.label === undefined ?
$t('employee_management.schedule_presets.preset_list_placeholder') :
current_preset.label }}
</span>
</template>
</q-select>
@ -81,6 +92,8 @@
/>
</transition>
</HorizontalSlideTransition>
</div>
<AddModifyDialogSchedulePreview :current-preset-id="current_preset.value" />
</div>
</template>

View File

@ -55,14 +55,14 @@
>
<q-tab
name="form"
icon="badge"
icon="las la-id-card"
:label="$q.screen.lt.sm ? '' : $t('employee_management.details_label')"
class="rounded-25 q-ma-xs"
style="border: 2px solid var(--q-accent);"
/>
<q-tab
name="access"
icon="key"
icon="las la-key"
:label="$q.screen.lt.sm ? '' : $t('employee_management.access_label')"
class="rounded-25 q-ma-xs"
style="border: 2px solid var(--q-accent);"
@ -81,7 +81,7 @@
animated
:transition-prev="$q.screen.lt.sm ? 'jump-down' : 'jump-left'"
:transition-next="$q.screen.lt.sm ? 'jump-up' : 'jump-right'"
class="bg-transparent"
class="bg-transparent full-height"
>
<q-tab-panel
name="form"

View File

@ -93,7 +93,7 @@
<q-btn
push
color="accent"
icon="person_add"
icon="las la-user-edit"
:label="$t('shared.label.add')"
class="text-uppercase"
@click.stop="_evt => employee_store.openAddModifyDialog()"

View File

@ -0,0 +1,134 @@
<script
setup
lang="ts"
>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import type { ShiftOption } from 'src/modules/timesheets/models/shift.models';
import type { SchedulePresetShift } from '../models/schedule-presets.models';
const { t } = useI18n();
const SHIFT_OPTIONS: ShiftOption[] = [
{ 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.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.HOLIDAY'), value: 'HOLIDAY', icon: 'forest', icon_color: 'green-8' },
{ label: t('timesheet.shift.types.SICK'), value: 'SICK', icon: 'medication_liquid', icon_color: 'light-blue-6' },
];
const shift = defineModel<SchedulePresetShift>('shift', { required: true });
const shift_type_selected = ref(SHIFT_OPTIONS[0]);
defineEmits<{
'click-delete': [void];
}>();
</script>
<template>
<div class="row col-auto flex-center">
<div class="col q-pa-xs">
<q-select
ref="select"
v-model="shift_type_selected"
:standout="$q.dark.isActive ? 'bg-blue-grey-3' : 'bg-blue-grey-9'"
dense
options-dense
hide-dropdown-icon
:menu-offset="[0, 10]"
menu-anchor="bottom middle"
menu-self="top middle"
:options="SHIFT_OPTIONS"
class="col rounded-5 bg-dark weekday-field"
popup-content-class="text-uppercase text-weight-bold text-center rounded-5"
popup-content-style="border: 2px solid var(--q-accent)"
@update:model-value="option => shift.type = option.value"
>
<template #selected-item="scope">
<div
class="row flex-center text-uppercase q-ma-none q-pa-none no-wrap ellipsis full-width"
:tabindex="scope.tabindex"
>
<q-icon
:name="scope.opt.icon"
:color="scope.opt.icon_color"
class="col-auto q-mx-xs"
/>
<span
style="line-height: 0.9em;"
class="col-auto ellipsis"
>{{ scope.opt.label }}</span>
</div>
</template>
</q-select>
</div>
<div class="col q-px-xs">
<q-input
v-model="shift.start_time"
standout
dense
hide-bottom-space
type="time"
class="text-uppercase weekday-field"
>
<template #prepend>
<div
class="column text-uppercase text-accent text-weight-bold full-height"
style="font-size: 0.5em; transform: translateY(4px);"
>
{{ $t('shared.misc.in') }} :
</div>
</template>
</q-input>
</div>
<div class="col q-px-xs">
<q-input
v-model="shift.end_time"
standout
dense
hide-bottom-space
type="time"
class="text-uppercase weekday-field"
>
<template #prepend>
<div
class="column text-uppercase text-accent text-weight-bold full-height"
style="font-size: 0.5em; transform: translateY(4px);"
>
{{ $t('shared.misc.out') }} :
</div>
</template>
</q-input>
</div>
<div class="col-auto q-px-xs">
<q-btn
dense
push
color="negative"
icon="clear"
size="sm"
tabindex="-1"
@click="$emit('click-delete')"
/>
</div>
</div>
</template>
<style scoped>
:deep(.q-field__native) {
padding: 0 !important;
}
.weekday-field :deep(.q-field__control) {
height: 25px;
min-height: 25px;
}
:deep(.q-field--auto-height.q-field--dense .q-field__native) {
min-height: 25px;
}
</style>

View File

@ -2,18 +2,29 @@
setup
lang="ts"
>
import SchedulePresetsDialogRow from './schedule-presets-dialog-row.vue';
import { useEmployeeListApi } from '../composables/use-employee-api';
import { SchedulePresetShift } from '../models/schedule-presets.models';
import { useSchedulePresetsStore } from 'src/stores/schedule-presets.store';
const schedule_preset_store = useSchedulePresetsStore();
const employee_list_api = useEmployeeListApi();
</script>
<template>
<q-dialog v-model="schedule_preset_store.is_manager_open">
<q-dialog
v-model="schedule_preset_store.is_manager_open"
full-width
>
<div
class="column flex-center bg-secondary rounded-10 shadow-24 full-width"
style="border: 2px solid var(--q-accent);"
class="column flex-center bg-secondary rounded-10 shadow-24"
style="border: 2px solid var(--q-accent); width: 50vw !important;"
>
<div class="row col-auto flex-center bg-primary full-width">
<div
class="row col-auto flex-center bg-primary full-width"
style="border-radius: 8px 8px 0 0;"
>
<span class="row col-auto text-uppercase text-weight-bold text-white q-py-sm">{{
schedule_preset_store.current_schedule_preset.id === -1 ?
$t('shared.label.add') :
@ -31,7 +42,15 @@
:placeholder="$t('employee_management.schedule_presets.preset_name_placeholder')"
class="text-uppercase"
input-class="text-weight-bold text-center"
/>
>
<template #before>
<q-icon
name="edit"
color="accent"
class="q-ml-sm"
/>
</template>
</q-input>
</div>
</div>
@ -39,35 +58,34 @@
<div
v-for="weekday of schedule_preset_store.current_schedule_preset.weekdays"
:key="weekday.day"
class="row col-auto items-center q-my-xs full-width shadow-2 q-px-sm bg-dark rounded-10"
class="row col-auto items-center q-my-xs shadow-2 bg-dark rounded-10 ellipsis"
style="min-height: 50px;"
>
<span class="col-2 text-uppercase text-weight-bold ellipsis">{{
<span class="col-2 text-uppercase text-weight-bold q-ml-sm ellipsis">{{
$t(`shared.weekday.${weekday.day.toLowerCase()}`) }}
</span>
<div class="col column">
<div
v-for="shift in weekday.shifts"
:key="shift.week_day"
class="row col-auto"
v-for="_shift, index in weekday.shifts"
:key="index"
>
<q-input
v-model="shift.end_time"
standout
dense
hide-bottom-space
class="text-uppercase weekday-field"
input-class="text-weight-bold"
>
<template #prepend>
<div class="column text-uppercase text-accent text-weight-bold full-height" style="font-size: 0.5em; transform: translateY(4px);">
{{ $t('shared.misc.out') }}
</div>
</template>
</q-input>
<SchedulePresetsDialogRow
v-model:shift="weekday.shifts[index]!"
@click-delete="weekday.shifts.splice(index, 1)"
/>
</div>
</div>
<div class="col-auto self-stretch">
<q-btn
square
icon="more_time"
color="accent"
class="full-height q-ma-none q-px-sm"
tabindex="-1"
@click="weekday.shifts.push(new SchedulePresetShift(weekday.day))"
/>
</div>
</div>
</div>
@ -79,21 +97,12 @@
push
dense
:color="schedule_preset_store.current_schedule_preset.name === '' ? 'grey-7' : 'accent'"
icon="save"
label="save"
class="col-3 q-px-sm q-mb-md"
icon="download"
:label="$t('shared.label.save')"
class="col-auto q-px-md q-mb-sm"
@click="employee_list_api.saveSchedulePreset"
/>
</div>
</div>
</q-dialog>
</template>
<style scoped>
:deep(.q-field__native) {
padding: 0 !important;
}
.weekday-field :deep(.q-field__control) {
height: 25px;
}
</style>

View File

@ -1,25 +1,46 @@
import { useEmployeeStore } from "src/stores/employee-store";
import { useSchedulePresetsStore } from "src/stores/schedule-presets.store";
import { SchedulePreset } from "../models/schedule-presets.models";
export const useEmployeeListApi = () => {
const employee_list_store = useEmployeeStore();
const employee_store = useEmployeeStore();
const schedule_preset_store = useSchedulePresetsStore();
const getEmployeeList = async (): Promise<void> => {
employee_list_store.is_loading = true;
employee_store.is_loading = true;
const success = await employee_list_store.getEmployeeList();
const success = await employee_store.getEmployeeList();
if (success) await schedule_preset_store.findSchedulePresetList();
employee_list_store.is_loading = false;
employee_store.is_loading = false;
};
const getEmployeeDetails = (email: string): Promise<void> => {
return employee_list_store.getEmployeeDetails(email);
return employee_store.getEmployeeDetails(email);
}
const setSchedulePreset = (preset_id: number) => {
schedule_preset_store.setCurrentSchedulePreset(preset_id);
employee_store.employee.preset_id = preset_id < 0 ? null : preset_id;
}
const saveSchedulePreset = async() => {
const preset = schedule_preset_store.current_schedule_preset;
const preset_shifts = preset.weekdays.flatMap(weekday => weekday.shifts);
const backend_preset = new SchedulePreset(preset.id, preset.name, preset.is_default, preset_shifts);
const success = await schedule_preset_store.updateSchedulePreset(backend_preset);
if (success) {
schedule_preset_store.is_manager_open = false;
await schedule_preset_store.findSchedulePresetList();
}
}
return {
getEmployeeList,
getEmployeeDetails,
setSchedulePreset,
saveSchedulePreset,
};
};

View File

@ -19,7 +19,7 @@ export class EmployeeProfile {
birth_date: string;
is_supervisor: boolean;
user_module_access: ModuleAccessName[];
preset_id?: number;
preset_id?: number | null;
constructor() {
this.first_name = '';
@ -115,4 +115,15 @@ export const employee_access_presets: Record<ModuleAccessPreset, ModuleAccessNam
'supervisor' : ['dashboard', 'employee_list', 'personal_profile', 'timesheets', 'timesheets_approval'],
'employee' : ['dashboard', 'timesheets', 'personal_profile', 'employee_list'],
'none' : [],
}
export const getEmployeeAccessOptionIcon = (module: ModuleAccessName): string => {
switch (module) {
case 'dashboard': return 'home';
case 'employee_list' : return 'groups';
case 'employee_management': return 'las la-user-edit';
case 'personal_profile': return 'las la-id-card';
case 'timesheets': return 'punch_clock';
case 'timesheets_approval': return 'event_available';
}
}

View File

@ -1,3 +1,5 @@
import type { ShiftType } from "src/modules/timesheets/models/shift.models";
export type Weekday = 'SUN' | 'MON' | 'TUE' | 'WED' | 'THU' | 'FRI' | 'SAT';
export const WEEKDAYS: Weekday[] = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT']
@ -8,18 +10,18 @@ export class SchedulePreset {
is_default: boolean;
shifts: SchedulePresetShift[];
constructor() {
this.id = -1;
this.name = 'default';
this.is_default = true;
this.shifts = [];
constructor(id?: number, name?: string, is_default?: boolean, shifts?: SchedulePresetShift[]) {
this.id = id ?? -1;
this.name = name ?? 'default';
this.is_default = is_default ?? false;
this.shifts = shifts ?? [];
}
}
class SchedulePresetShift {
export class SchedulePresetShift {
preset_id: number;
week_day: Weekday;
type: string;
type: ShiftType;
start_time: string;
end_time: string;
is_remote: boolean;
@ -27,7 +29,7 @@ class SchedulePresetShift {
constructor(weekday: Weekday) {
this.preset_id = -1;
this.week_day = weekday;
this.type = '';
this.type = 'REGULAR';
this.start_time = '00:00';
this.end_time = '00:00';
this.is_remote = false;

View File

@ -3,13 +3,13 @@ import type { SchedulePreset } from "src/modules/employee-list/models/schedule-p
import type { BackendResponse } from "src/modules/shared/models/backend-response.models";
export const SchedulePresetsService = {
createSchedulePresets: async (new_schedule: SchedulePreset) => {
const response = await api.post(`/schedule-presets/create/`, new_schedule);
createSchedulePresets: async (preset: SchedulePreset) => {
const response = await api.post(`/schedule-presets/create/`, preset);
return response.data;
},
updateSchedulePresets: async (preset_id: number, dto: Partial<SchedulePreset>) => {
const response = await api.patch(`/schedule-presets/update/${preset_id}`, dto);
updateSchedulePresets: async (preset: SchedulePreset): Promise<BackendResponse<boolean>> => {
const response = await api.patch(`/schedule-presets/update`, preset);
return response.data;
},

View File

@ -16,6 +16,7 @@
const shift_rules = useShiftRules(t('timesheet.errors.SHIFT_TIME_REQUIRED'),);
const COMMENT_LENGTH_MAX = 280;
const SHIFT_OPTIONS: ShiftOption[] = [
{ 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' },
@ -24,6 +25,7 @@
{ 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: 'light-blue-6' },
];
const shift = defineModel<Shift>('shift', { required: true });
const shift_type_selected = ref(SHIFT_OPTIONS.find(option => option.value == shift.value.type));
const select_ref = useTemplateRef<QSelect>('select');

View File

@ -11,6 +11,10 @@ export const useSchedulePresetsStore = defineStore('schedule_presets_store', ()
const is_manager_open = ref(false);
const setCurrentSchedulePreset = (preset_id: number) => {
if (preset_id === -1) {
current_schedule_preset.value = new SchedulePresetFrontend;
return;
}
current_schedule_preset.value = new SchedulePresetFrontend(schedule_presets.value.find(preset => preset.id === preset_id)!)
};
@ -34,9 +38,10 @@ export const useSchedulePresetsStore = defineStore('schedule_presets_store', ()
}
};
const updateSchedulePreset = async (): Promise<boolean> => {
const updateSchedulePreset = async (preset: SchedulePreset): Promise<boolean> => {
try {
return true;
const response = await SchedulePresetsService.updateSchedulePresets(preset);
return response.success;
} catch (error) {
console.error('DEV ERROR || error while updating schedule preset: ', error);
return false;