targo-frontend/src/modules/timesheets/components/mobile/shift-list-day-row-mobile.vue
louispaulb 528c860a32
Some checks failed
Node-CI / build (push) Successful in 3m9s
Node-CI / lint (push) Successful in 3m23s
Node-CI / test (push) Successful in 4m44s
Node-CI / deploy (push) Failing after 40s
Modernize UI: outlined icons, improved timesheet layout, bug fixes
Design:
- Switch to material-icons-outlined globally
- White header with green Targo logo
- Sidebar: outlined icons, active state with green fill + glow
- Dashboard: Material icons replace broken PNG images
- Shift types: outlined icons (light_mode, dark_mode, warning_amber, etc.)
- Modern scrollbar, card borders, font smoothing

Timesheet:
- Day-by-day mini bars in weekly overview (S1/S2 with D L M M J V S headers)
- Clickable bars scroll to corresponding day card with flash animation
- Double-click empty card to add shift
- Weekend cards: gray background + left border, text stays readable
- Whole page scrolls naturally (removed internal q-scroll-area)
- Week column headers (Heures Semaine 1/2)
- Monday separator line
- Today: green glow + CSS circle indicator
- more_time icon replaces add_circle

Overview cards:
- PTO: table format, no duplicate "vacances" header, aligned values
- Hours: weekly chips side by side
- Expenses: total badge next to button, aligned under hours card

Mobile:
- Modernized day cards with date header (number + weekday + month)
- Delete button moved to bottom of shift card
- Outlined icons unified with desktop
- Weekly overview: compact "Sem. 1/2" with day-dot bars

Auth:
- Dev bypass: oidcLogin tries getProfile first before OIDC popup
- Fixed floating-promises lint errors

Bug fixes:
- Removed console.log in shift-list-day
- Fixed broken asset paths (src/assets → public/img)
- circle.png replaced with CSS-only today indicator

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 13:02:38 -04:00

354 lines
15 KiB
Vue

<script
setup
lang="ts"
>
import TargoInput from 'src/modules/shared/components/targo-input.vue';
import { computed, onMounted, ref } from 'vue';
import { colors, getCssVar, QSelect } from 'quasar';
import { useUiStore } from 'src/stores/ui-store';
import { getCurrentDailyMinutesWorked, getShiftOptions, getTimeStringFromMinutes, SHIFT_OPTIONS } from 'src/modules/timesheets/utils/shift.util';
import type { Shift, ShiftOption, ShiftType } from 'src/modules/timesheets/models/shift.models';
import { getHoursMinutesStringFromHoursFloat } from 'src/utils/date-and-time-utils';
// ========== state ========================================
const SHIFT_TYPES_WITH_PREDEFINED_TIMES: ShiftType[] = ['HOLIDAY', 'SICK', 'VACATION'];
// const COMMENT_LENGTH_MAX = 280;
const shift = defineModel<Shift>('shift', { required: true });
const {
currentShifts,
hasShiftAfter = false,
isTimesheetApproved = false,
errorMessage = undefined,
expectedDailyHours = 8,
} = defineProps<{
currentShifts: Shift[];
hasShiftAfter?: boolean;
isTimesheetApproved?: boolean;
errorMessage?: string | undefined;
expectedDailyHours?: number;
isHoliday?: boolean;
}>();
const emit = defineEmits<{
'requestDelete': [void];
'onTimeFieldBlur': [void];
}>();
const uiStore = useUiStore();
const shiftTypeSelected = ref(SHIFT_OPTIONS.find(option => option.value == shift.value.type));
const selectRef = ref<QSelect | null>(null);
// const isShowingCommentPopup = ref(false);
const errorMessageRow = ref('');
const isShowingPredefinedTime = ref(shift.value.type === 'HOLIDAY');
const predefinedHoursString = ref('');
const predefinedHoursBgColor = ref(`bg-${shiftTypeSelected.value?.icon_color ?? ''}`);
// ========== computed ========================================
const commentLength = computed(() => shift.value.comment?.length ?? 0);
const isApproved = computed(() => isTimesheetApproved || shift.value.is_approved);
const hasPTO = computed(() => currentShifts.some(shift => SHIFT_TYPES_WITH_PREDEFINED_TIMES.includes(shift.type)));
// ========== methods =========================================
const onBlurShiftTypeSelect = () => {
if (shiftTypeSelected.value === undefined) {
shift.value.type = 'REGULAR';
shift.value.id = 0;
emit('requestDelete');
}
};
const onTimeFieldBlur = (time_string: string) => {
if (time_string.length < 1 || !time_string) {
shift.value.has_error = true;
errorMessageRow.value = 'timesheet.errors.SHIFT_TIME_REQUIRED';
} else {
shift.value.has_error = false;
errorMessageRow.value = '';
emit('onTimeFieldBlur');
}
}
const onShiftTypeChange = (option: ShiftOption) => {
shift.value.type = option.value;
if (SHIFT_TYPES_WITH_PREDEFINED_TIMES.includes(option.value)) {
predefinedHoursBgColor.value = `bg-${option.icon_color}`;
shift.value.start_time = '00:00';
if (option.value === 'SICK' || option.value === 'VACATION') {
const workedMinutes = getCurrentDailyMinutesWorked(currentShifts);
const expectedWorkedMinutes = expectedDailyHours * 60;
const leftOverMinutes = expectedWorkedMinutes - workedMinutes;
shift.value.end_time = getTimeStringFromMinutes(leftOverMinutes);
isShowingPredefinedTime.value = false;
} else {
isShowingPredefinedTime.value = true;
predefinedHoursString.value = getHoursMinutesStringFromHoursFloat(expectedDailyHours);
shift.value.end_time = getTimeStringFromMinutes(expectedDailyHours * 60);
}
} else
isShowingPredefinedTime.value = false;
}
onMounted(() => {
if (uiStore.focusNextComponent) {
selectRef.value?.focus();
selectRef.value?.showPopup();
shiftTypeSelected.value = undefined;
uiStore.focusNextComponent = false;
}
if (errorMessage)
errorMessageRow.value = errorMessage;
});
</script>
<template>
<div class="column">
<div class="column q-pa-sm">
<div class="col column">
<div class="row justify-center q-pb-xs q-px-sm full-width">
<!-- shift type -->
<q-select
ref="selectRef"
v-model="shiftTypeSelected"
dense
borderless
color="accent"
label-color="white"
stack-label
label-slot
hide-dropdown-icon
:readonly="isApproved"
:options="getShiftOptions(hasPTO, currentShifts.length > 1)"
lazy-rules
no-error-icon
hide-bottom-space
options-selected-class="text-white text-bold bg-accent"
class="col q-px-md rounded-5 inset-shadow text-uppercase"
:class="isApproved ? 'bg-white' : ($q.dark.isActive ? 'bg-primary' : 'bg-secondary')"
popup-content-class="text-uppercase text-weight-medium rounded-5 shadow-12 z-top"
popup-content-style="border: 1px solid var(--q-primary)"
menu-anchor="bottom middle"
menu-self="top middle"
:menu-offset="[0, 5]"
:style="`border: 1px solid ${$q.dark.isActive ? 'var(--q-secondary)' : colors.getPaletteColor('blue-grey-4')}; background-color: ${colors.lighten(getCssVar('secondary')!, 50)}`"
@blur="onBlurShiftTypeSelect"
@update:model-value="onShiftTypeChange"
>
<template #selected-item="scope">
<div
class="row items-center text-weight-bold q-pt-sm no-wrap ellipsis"
:tabindex="scope.tabindex"
>
<q-icon
:name="scope.opt.icon"
:color="shift.is_approved ? 'accent' : scope.opt.icon_color"
size="sm"
class="col-auto"
:class="shift.is_approved ? 'q-mx-xs' : 'q-mr-xs'"
/>
<span
style="font-size: 1.3em;"
class="col ellipsis"
:class="shift.is_approved ? 'text-accent' : ''"
>
{{ $t(scope.opt.label) }}
</span>
</div>
</template>
<template #option="scope">
<q-item
clickable
v-bind="scope.itemProps"
>
<q-item-section avatar>
<q-icon :name="scope.opt.icon" />
</q-item-section>
<q-item-section class="text-left">
{{ $t(scope.label) }}
</q-item-section>
</q-item>
</template>
<!-- work-from-home toggle -->
<template #after>
<q-icon
v-if="shift.is_approved"
:name="shift.is_remote ? 'o_laptop' : 'o_business'"
size="1.2em"
color="accent"
class="q-mr-sm"
>
<q-tooltip
anchor="top middle"
self="bottom middle"
:offset="[0, 10]"
class="text-uppercase text-weight-bold text-white bg-primary"
>
{{ shift.is_remote ? $t('timesheet.shift.types.REMOTE') :
$t('timesheet.shift.types.OFFICE') }}
</q-tooltip>
</q-icon>
<q-toggle
v-else
v-model="shift.is_remote"
:disable="shift.is_approved"
dense
keep-color
size="3em"
:color="isHoliday ? 'purple-5' : 'accent'"
icon="las la-building"
checked-icon="las la-laptop"
>
<q-tooltip
anchor="top middle"
self="bottom middle"
:offset="[0, 10]"
class="text-uppercase text-weight-medium text-white bg-accent"
>
{{ shift.is_remote ? $t('timesheet.shift.types.REMOTE') :
$t('timesheet.shift.types.OFFICE') }}
</q-tooltip>
</q-toggle>
</template>
<template #label>
<span
class="text-weight-medium text-uppercase q-px-sm no-pointer-events"
:class="$q.dark.isActive ? 'bg-secondary' : 'bg-blue-grey-7'"
>
{{ $t('timesheet.shift.types.label') }}
</span>
</template>
</q-select>
</div>
<div
v-if="isShowingPredefinedTime"
class="col row items-start text-uppercase rounded-5 q-pa-xs relative-position"
>
<div
class="absolute-full rounded-5 q-mx-sm q-my-xs"
:class="predefinedHoursBgColor"
style="opacity: 0.3;"
></div>
<span class="col text-center text-uppercase text-h6 text-bold q-py-xs">
{{ getHoursMinutesStringFromHoursFloat(expectedDailyHours) }}
</span>
</div>
<div
v-else
class="col row items-start text-uppercase rounded-5 q-pt-sm"
>
<!-- punch in field -->
<div class="col">
<TargoInput
v-model="shift.start_time"
no-top-padding
dense
type="time"
:readonly="isApproved"
:background-color="isApproved ? 'white' : undefined"
:input-text-color="isApproved ? 'accent' : ''"
:label="$t('shared.misc.in')"
:error="shift.has_error"
@blur="onTimeFieldBlur(shift.start_time)"
/>
</div>
<!-- punch out field -->
<div class="col">
<TargoInput
v-model="shift.end_time"
no-top-padding
dense
type="time"
:readonly="isApproved"
:background-color="isApproved ? 'white' : undefined"
:input-text-color="isApproved ? 'accent' : ''"
:label="$t('shared.misc.out')"
:error="shift.has_error"
@blur="onTimeFieldBlur(shift.end_time)"
/>
</div>
</div>
<div class="col-auto q-pt-md">
<TargoInput
v-model="shift.comment"
no-top-padding
dense
:readonly="isApproved"
:background-color="isApproved ? 'white' : undefined"
:input-text-color="isApproved ? 'accent' : ''"
:label="$t('timesheet.expense.employee_comment')"
:append-content="isApproved ? '' : `${commentLength ?? 0}/280`"
/>
</div>
</div>
<!-- Delete button at bottom of card -->
<div
v-if="!shift.is_approved"
class="row justify-end q-px-sm q-pb-xs"
>
<q-btn
flat
dense
no-caps
color="negative"
icon="o_delete_outline"
label="Supprimer"
size="sm"
class="text-weight-medium"
@click="$emit('requestDelete')"
/>
</div>
</div>
<q-separator
v-if="hasShiftAfter"
spaced
size="2px"
:color="isApproved ? 'accent2' : 'accent'"
class="q-mx-lg"
/>
</div>
</template>
<style scoped>
:deep(.q-field--error) {
background-color: var(--q-negative) !important;
}
:deep(.q-field--error .q-field__bottom) {
color: white;
font-weight: 900;
border-radius: 0 0 5px 5px;
padding-top: 0;
align-items: center;
}
:deep(.q-field--float .q-field__label) {
transform: translate(-17px, -60%) scale(0.75) !important;
border-radius: 10px 10px 10px 0px;
}
:deep(.q-field--auto-height.q-field--labeled .q-field__control-container) {
padding: 0;
}
</style>