Modernize UI: outlined icons, improved timesheet layout, bug fixes
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

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>
This commit is contained in:
louispaulb 2026-03-25 13:02:38 -04:00
parent 15e60c9ed2
commit 528c860a32
36 changed files with 1720 additions and 1051 deletions

View File

@ -1 +1 @@
VITE_TARGO_BACKEND_URL=PREFIX_BACKEND_URL VITE_TARGO_BACKEND_URL=http://localhost:3000/

BIN
public/img/circle.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -0,0 +1 @@
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 326.39 70.35"><defs><style>.cls-1{fill:#019547;}</style></defs><path class="cls-1" d="M25.83,15H8.41a4.14,4.14,0,0,1-3.89-2.71L0,1.18H66.59L62.07,12.27A4.14,4.14,0,0,1,58.18,15H40.76V69.19H25.81Z"/><path class="cls-1" d="M90.74.68h18.55a5.63,5.63,0,0,1,5.63,5.6l.26,62.91H99.55L99.76,54H71.87L66.41,65.51a6.45,6.45,0,0,1-5.84,3.68H49.35L73.53,12.11A18.7,18.7,0,0,1,90.74.68M100,40.73l.07-26h-8a6.75,6.75,0,0,0-6.26,4.18L76.73,40.73Z"/><path class="cls-1" d="M124.51,6.81a5.64,5.64,0,0,1,5.65-5.65H155.6c8.64,0,15.37,2.44,19.81,6.91,3.81,3.78,5.84,9.14,5.84,15.53v.18c0,11-5.92,17.87-14.59,21.1l16.61,24.29h-14a6.51,6.51,0,0,1-5.39-2.87L151.21,47.41H139.44V69.17h-15Zm30.12,27.41c7.28,0,11.45-3.89,11.45-9.62v-.21c0-6.42-4.46-9.7-11.74-9.7H139.46V34.22Z"/><path class="cls-1" d="M184.74,35.37v-.18C184.74,15.85,199.8,0,220.4,0,232.65,0,240,3.31,247.13,9.33l-6.28,7.57a5.18,5.18,0,0,1-6.84,1,23.71,23.71,0,0,0-14.11-4.13c-10.88,0-19.52,9.62-19.52,21.18v.19c0,12.43,8.54,21.57,20.6,21.57a24,24,0,0,0,14.09-4.07V42.91h-8.68A6.41,6.41,0,0,1,220,36.53V30h29.54V59.52a44,44,0,0,1-29,10.78c-21.18,0-35.74-14.8-35.74-34.93"/><path class="cls-1" d="M254.09,35.37v-.18C254.09,15.85,269.33,0,290.33,0s36.06,15.66,36.06,35v.18c0,19.34-15.27,35.19-36.24,35.19s-36.06-15.64-36.06-35m56.63,0v-.18c0-11.67-8.54-21.39-20.6-21.39S269.73,23.34,269.73,35v.18c0,11.67,8.54,21.37,20.6,21.37S310.72,47,310.72,35.37"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1 @@
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 326.39 70.35"><defs><style>.cls-1{fill:#ffffff;}</style></defs><path class="cls-1" d="M25.83,15H8.41a4.14,4.14,0,0,1-3.89-2.71L0,1.18H66.59L62.07,12.27A4.14,4.14,0,0,1,58.18,15H40.76V69.19H25.81Z"/><path class="cls-1" d="M90.74.68h18.55a5.63,5.63,0,0,1,5.63,5.6l.26,62.91H99.55L99.76,54H71.87L66.41,65.51a6.45,6.45,0,0,1-5.84,3.68H49.35L73.53,12.11A18.7,18.7,0,0,1,90.74.68M100,40.73l.07-26h-8a6.75,6.75,0,0,0-6.26,4.18L76.73,40.73Z"/><path class="cls-1" d="M124.51,6.81a5.64,5.64,0,0,1,5.65-5.65H155.6c8.64,0,15.37,2.44,19.81,6.91,3.81,3.78,5.84,9.14,5.84,15.53v.18c0,11-5.92,17.87-14.59,21.1l16.61,24.29h-14a6.51,6.51,0,0,1-5.39-2.87L151.21,47.41H139.44V69.17h-15Zm30.12,27.41c7.28,0,11.45-3.89,11.45-9.62v-.21c0-6.42-4.46-9.7-11.74-9.7H139.46V34.22Z"/><path class="cls-1" d="M184.74,35.37v-.18C184.74,15.85,199.8,0,220.4,0,232.65,0,240,3.31,247.13,9.33l-6.28,7.57a5.18,5.18,0,0,1-6.84,1,23.71,23.71,0,0,0-14.11-4.13c-10.88,0-19.52,9.62-19.52,21.18v.19c0,12.43,8.54,21.57,20.6,21.57a24,24,0,0,0,14.09-4.07V42.91h-8.68A6.41,6.41,0,0,1,220,36.53V30h29.54V59.52a44,44,0,0,1-29,10.78c-21.18,0-35.74-14.8-35.74-34.93"/><path class="cls-1" d="M254.09,35.37v-.18C254.09,15.85,269.33,0,290.33,0s36.06,15.66,36.06,35v.18c0,19.34-15.27,35.19-36.24,35.19s-36.06-15.64-36.06-35m56.63,0v-.18c0-11.67-8.54-21.39-20.6-21.39S269.73,23.34,269.73,35v.18c0,11.67,8.54,21.37,20.6,21.37S310.72,47,310.72,35.37"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

View File

@ -57,7 +57,7 @@ export default defineConfig((ctx) => {
// rebuildCache: true, // rebuilds Vite/linter/etc cache on startup // rebuildCache: true, // rebuilds Vite/linter/etc cache on startup
// publicPath: '/', publicPath: '/assets/targo-app/',
// analyze: true, // analyze: true,
// env: {}, // env: {},
// rawDefine: {} // rawDefine: {}
@ -117,7 +117,7 @@ export default defineConfig((ctx) => {
dark: 'auto', dark: 'auto',
}, },
// iconSet: 'material-icons', // Quasar icon set iconSet: 'material-icons-outlined', // Modern outlined icons
// lang: 'en-US', // Quasar language pack // lang: 'en-US', // Quasar language pack
// For special cases outside of where the auto-import strategy can have an impact // For special cases outside of where the auto-import strategy can have an impact

View File

@ -50,12 +50,13 @@ body.body--dark {
background-color: #95f0a1B0; background-color: #95f0a1B0;
} }
/* ── Modern button styling ── */
.q-btn--push::before { .q-btn--push::before {
border-bottom: 4px solid rgba(0,0,0, 0.25); border-bottom: 3px solid rgba(0,0,0, 0.18);
} }
.q-btn--push:active { .q-btn--push:active {
transform: translateY(3px); transform: translateY(2px);
} }
.q-btn--push:active::before { .q-btn--push:active::before {
@ -75,11 +76,11 @@ input[type=number] {
} }
.q-field--dark .q-field__control::before { .q-field--dark .q-field__control::before {
border-color: #fff3; border-color: #fff2;
} }
.q-field--dark .q-field__control:hover::before, .q-field--outlined .q-field__control:hover::before { .q-field--dark .q-field__control:hover::before, .q-field--outlined .q-field__control:hover::before {
border-color: var(--q-accent2); border-color: var(--q-accent);
border-width: 2px; border-width: 2px;
} }
@ -92,3 +93,125 @@ input[type=number] {
text-shadow: 2px 0 var(--q-primary), -2px 0 var(--q-primary), 0 2px var(--q-primary), 0 -2px var(--q-primary), text-shadow: 2px 0 var(--q-primary), -2px 0 var(--q-primary), 0 2px var(--q-primary), 0 -2px var(--q-primary),
1px 1px var(--q-primary), -1px -1px var(--q-primary), 1px -1px var(--q-primary), -1px 1px var(--q-primary); 1px 1px var(--q-primary), -1px -1px var(--q-primary), 1px -1px var(--q-primary), -1px 1px var(--q-primary);
} }
/* ── Modern UI enhancements ── */
/* Smoother font rendering */
body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Today's row: prominent indicator */
.shift-today-glow {
box-shadow: 0 0 0 2.5px var(--q-accent), 0 4px 20px rgba(14, 165, 80, 0.2) !important;
position: relative;
z-index: 2;
}
/* Day rows: subtle hover lift */
.shift-day-row {
transition: transform 0.15s ease, box-shadow 0.15s ease;
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 16px rgba(0,0,0,0.10) !important;
}
}
/* Empty day card: dashed border invite */
.shift-day-empty {
cursor: pointer;
.bg-dark {
border: 1.5px dashed rgba(255,255,255,0.12) !important;
transition: border-color 0.2s;
}
&:hover .bg-dark {
border-color: var(--q-accent) !important;
}
}
/* Cleaner card borders */
.q-card {
border: 1px solid rgba(0,0,0,0.06);
}
body.body--dark .q-card {
border-color: rgba(255,255,255,0.06);
}
/* Outlined icon style consistency */
.q-icon {
font-weight: 300;
}
/* Modern header bar */
.q-header {
backdrop-filter: blur(12px);
.q-toolbar {
min-height: 56px;
}
}
/* Shift type selector: prevent label truncation */
@media (min-width: 1024px) {
.q-select .q-field__native { min-width: 120px; }
}
/* Cleaner input fields */
.q-field--outlined .q-field__control {
border-radius: 8px;
}
/* Sidebar: cleaner look */
.q-drawer {
.q-item {
border-radius: 8px;
margin: 2px 8px;
&.q-router-link--active {
font-weight: 700;
}
}
}
/* Modern scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(0,0,0,0.15);
border-radius: 3px;
}
body.body--dark ::-webkit-scrollbar-thumb {
background: rgba(255,255,255,0.12);
}
/* Weekly overview bars */
.weekly-bar-fill {
transition: width 0.4s ease, background-color 0.3s;
}
/* Approved badge: outlined style */
.q-badge {
font-weight: 600;
letter-spacing: 0.02em;
}
/* Hover lift utility */
.hover-lift {
transition: background 0.15s, transform 0.15s;
&:hover {
background: rgba(14, 165, 80, 0.08);
}
}
/* Tooltip: modern look */
.q-tooltip {
font-size: 0.75rem;
font-weight: 500;
border-radius: 6px;
padding: 4px 10px;
}

View File

@ -1,41 +1,31 @@
// Quasar SCSS (& Sass) Variables // Quasar SCSS (& Sass) Variables
// -------------------------------------------------- // --------------------------------------------------
// To customize the look and feel of this app, you can override // Modernized theme cleaner, lighter feel with outline aesthetic
// the Sass/SCSS variables found in Quasar's source Sass/SCSS files.
// Check documentation for full list of Quasar variables $primary : #1e1e2a;
$secondary : #eef1f5;
// Your own variables (that are declared here) and Quasar's own $accent : #0ea550;
// ones will be available out of the box in your .vue/.scss/.sass files
// It's highly recommended to change the default colors
// to match your app's branding.
// Tip: Use the "Theme Builder" on Quasar's documentation website.
$primary : #30303A;
$secondary : #DAE0E7;
$accent : #0c9a3b;
$dark-shadow-color : #000; $dark-shadow-color : #000;
$elevation-dark-umbra : rgba($dark-shadow-color, .2); $elevation-dark-umbra : rgba($dark-shadow-color, .15);
$elevation-dark-penumbra : rgba($dark-shadow-color, .14); $elevation-dark-penumbra : rgba($dark-shadow-color, .10);
$elevation-dark-ambient : rgba($dark-shadow-color, .12); $elevation-dark-ambient : rgba($dark-shadow-color, .08);
$layout-shadow-dark : 0 0 5px 5px rgba($dark-shadow-color, 0.5); $layout-shadow-dark : 0 1px 8px rgba($dark-shadow-color, 0.25);
$input-text-color : #455A64; $input-text-color : #37474F;
$input-autofill-color : #AAD5C4; $input-autofill-color : #c8e6d5;
$field-dense-label-top : 5px !default; $field-dense-label-top : 5px !default;
$field-dense-label-font-size : 16px !default; $field-dense-label-font-size : 14px !default;
$button-shadow : 0 0 0 transparent; $button-shadow : 0 0 0 transparent;
$dark : #40404C; $dark : #2a2a3a;
$dark-page : #343444; $dark-page : #222233;
$positive : #21ba45; $positive : #21ba45;
$negative : #e6364b; $negative : #e6364b;
$info : #6bb9e7; $info : #5ba8d9;
$warning : #e4a944; $warning : #e4a944;
$white : white; $white : white;

View File

@ -10,49 +10,88 @@ import { useUiStore } from 'src/stores/ui-store';
</script> </script>
<template> <template>
<q-header elevated> <q-header class="app-header">
<q-toolbar class="q-px-sm"> <q-toolbar class="q-px-md">
<q-toolbar-title> <!-- Left: menu + logo -->
<q-btn <q-btn
flat flat
dense dense
color="white" round
icon="o_menu"
color="grey-7"
@click="uiStore.toggleRightDrawer" @click="uiStore.toggleRightDrawer"
class="q-px-none"
>
<q-icon
name="menu"
size="lg"
class="q-mr-lg"
/> />
<q-img <q-img
src="src/assets/logo-targo-white.svg" src="img/logo-targo-green.svg"
fit="contain" fit="contain"
width="150px" width="120px"
height="30px" height="28px"
class="q-ml-sm cursor-pointer"
@click="$router.push('/')"
/> />
</q-btn>
</q-toolbar-title>
<q-space />
<!-- Right: user info -->
<div class="row items-center no-wrap">
<q-icon <q-icon
name="las la-user-circle" name="o_account_circle"
size="md" size="sm"
class="q-px-sm" color="accent"
class="q-mr-sm"
/> />
<div v-if="$q.platform.is.mobile" class="text-uppercase text-bold text-h4 text-accent"> <div v-if="$q.platform.is.mobile" class="user-initials">
{{ authStore.user?.first_name.charAt(0) }}{{ authStore.user?.last_name.charAt(0) }} {{ authStore.user?.first_name?.charAt(0) }}{{ authStore.user?.last_name?.charAt(0) }}
</div> </div>
<div v-if="!$q.platform.is.mobile" class="row items-end"> <div v-else class="row items-baseline no-wrap">
<div class="text-uppercase text-h4 text-weight-medium text-accent q-px-xs"> <span class="user-first">{{ authStore.user?.first_name }}</span>
{{ authStore.user?.first_name }} <span class="user-last">{{ authStore.user?.last_name }}</span>
</div>
<div class="text-uppercase text-h6 text-weight-light q-pr-md">
{{ authStore.user?.last_name }}
</div> </div>
</div> </div>
</q-toolbar> </q-toolbar>
</q-header> </q-header>
</template> </template>
<style scoped>
.app-header {
background: #fff !important;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
box-shadow: 0 1px 8px rgba(0, 0, 0, 0.04) !important;
}
:global(body.body--dark) .app-header {
background: #1a1a24 !important;
border-bottom-color: rgba(255, 255, 255, 0.06);
box-shadow: 0 1px 8px rgba(0, 0, 0, 0.2) !important;
}
.user-initials {
font-size: 1rem;
font-weight: 800;
text-transform: uppercase;
color: var(--q-accent);
}
.user-first {
font-size: 1.1rem;
font-weight: 700;
text-transform: uppercase;
color: var(--q-accent);
letter-spacing: 0.02em;
}
.user-last {
font-size: 0.85rem;
font-weight: 400;
text-transform: uppercase;
color: #888;
margin-left: 6px;
}
:global(body.body--dark) .user-last {
color: #999;
}
</style>

View File

@ -12,12 +12,12 @@
import { useAuthApi } from 'src/modules/auth/composables/use-auth-api'; import { useAuthApi } from 'src/modules/auth/composables/use-auth-api';
const DRAWER_BUTTONS: { i18n_key: string, icon: string, route: RouteNames, required_module?: UserModuleAccess }[] = [ const DRAWER_BUTTONS: { i18n_key: string, icon: string, route: RouteNames, required_module?: UserModuleAccess }[] = [
{ i18n_key: 'nav_bar.home', icon: "home", route: RouteNames.DASHBOARD, required_module: ModuleNames.DASHBOARD }, { i18n_key: 'nav_bar.home', icon: "o_home", route: RouteNames.DASHBOARD, required_module: ModuleNames.DASHBOARD },
{ i18n_key: 'nav_bar.timesheet_approvals', icon: "event_available", route: RouteNames.TIMESHEET_APPROVALS, required_module: ModuleNames.TIMESHEETS_APPROVAL }, { i18n_key: 'nav_bar.timesheet_approvals', icon: "o_event_available", route: RouteNames.TIMESHEET_APPROVALS, required_module: ModuleNames.TIMESHEETS_APPROVAL },
{ i18n_key: 'nav_bar.employee_list', icon: "groups", route: RouteNames.EMPLOYEE_LIST, required_module: ModuleNames.EMPLOYEE_LIST }, { i18n_key: 'nav_bar.employee_list', icon: "o_groups", route: RouteNames.EMPLOYEE_LIST, required_module: ModuleNames.EMPLOYEE_LIST },
{ i18n_key: 'nav_bar.timesheet', icon: "punch_clock", route: RouteNames.TIMESHEET, required_module: ModuleNames.TIMESHEETS }, { i18n_key: 'nav_bar.timesheet', icon: "o_schedule", route: RouteNames.TIMESHEET, required_module: ModuleNames.TIMESHEETS },
{ i18n_key: 'nav_bar.profile', icon: "account_box", route: RouteNames.PROFILE, required_module: ModuleNames.PERSONAL_PROFILE }, { i18n_key: 'nav_bar.profile', icon: "o_person", route: RouteNames.PROFILE, required_module: ModuleNames.PERSONAL_PROFILE },
{ i18n_key: 'nav_bar.help', icon: "contact_support", route: RouteNames.HELP }, { i18n_key: 'nav_bar.help', icon: "o_help_outline", route: RouteNames.HELP },
] ]
const q = useQuasar(); const q = useQuasar();
@ -67,18 +67,21 @@ import { useAuthApi } from 'src/modules/auth/composables/use-auth-api';
> >
<div <div
v-if="button.required_module ? authStore.user?.user_module_access.includes(button.required_module) : true" v-if="button.required_module ? authStore.user?.user_module_access.includes(button.required_module) : true"
class="row items-center full-width q-py-sm cursor-pointer" class="row items-center full-width q-py-sm q-my-xs cursor-pointer rounded-10 q-mx-xs"
:class="$router.currentRoute.value.name === button.route ? ($q.dark.isActive ? 'bg-green-10' : 'bg-green-2') : ''" :class="$router.currentRoute.value.name === button.route
? ($q.dark.isActive ? 'bg-green-10' : 'bg-accent text-white')
: 'hover-lift'"
:style="$router.currentRoute.value.name === button.route ? 'box-shadow: 0 2px 12px rgba(14,165,80,0.3)' : ''"
> >
<q-icon <q-icon
:name="button.icon" :name="button.icon"
color="accent" :color="$router.currentRoute.value.name === button.route ? 'white' : 'accent'"
size="lg" size="md"
class="col-auto q-pl-sm" class="col-auto q-pl-sm"
/> />
<div <div
class="col text-uppercase text-weight-bold text-h6 q-pl-sm" class="col text-uppercase text-weight-bold text-body1 q-pl-sm"
:class="$q.platform.is.mobile ? '' : 'q-mini-drawer-hide'" :class="$q.platform.is.mobile ? '' : 'q-mini-drawer-hide'"
> >
{{ $t(button.i18n_key) }} {{ $t(button.i18n_key) }}
@ -93,7 +96,7 @@ import { useAuthApi } from 'src/modules/auth/composables/use-auth-api';
@click="handleLogout" @click="handleLogout"
> >
<q-icon <q-icon
name="exit_to_app" name="o_logout"
color="accent" color="accent"
size="lg" size="lg"
class="col-auto q-pl-sm" class="col-auto q-pl-sm"

View File

@ -17,8 +17,8 @@
if (is_employee_email.value) return; if (is_employee_email.value) return;
} }
const onClickEmployeeConnect = () => { const onClickEmployeeConnect = async () => {
auth_api.oidcLogin(); await auth_api.oidcLogin();
} }
</script> </script>

View File

@ -7,8 +7,8 @@ export const useAuthApi = () => {
authStore.login(); authStore.login();
}; };
const oidcLogin = () => { const oidcLogin = async () => {
authStore.oidcLogin(); await authStore.oidcLogin();
}; };
const logout = async () => { const logout = async () => {

View File

@ -42,7 +42,7 @@
:class="$q.platform.is.mobile ? 'no-wrap' : ''" :class="$q.platform.is.mobile ? 'no-wrap' : ''"
> >
<q-img <q-img
src="src/assets/targo_building.png" src="img/targo_building.png"
position="50% 25%" position="50% 25%"
fit="cover" fit="cover"
class="col-9" class="col-9"
@ -72,7 +72,7 @@
:class="$q.platform.is.mobile ? 'no-wrap' : ''" :class="$q.platform.is.mobile ? 'no-wrap' : ''"
> >
<q-img <q-img
src="src/assets/targo_help_banner.png" src="img/targo_help_banner.png"
position="50% 25%" position="50% 25%"
fit="none" fit="none"
class="col-9" class="col-9"

View File

@ -2,49 +2,34 @@
setup setup
lang="ts" lang="ts"
> >
/* eslint-disable */
import { date, useQuasar } from 'quasar'; import { date, useQuasar } from 'quasar';
import { computed, onMounted, onUpdated, ref } from 'vue'; import { computed, onMounted, onUpdated, ref } from 'vue';
const { title, startDate = "", endDate = "" } = defineProps<{ const { title: _title, startDate = "", endDate = "" } = defineProps<{
title: string; title: string;
startDate?: string; startDate?: string;
endDate?: string; endDate?: string;
}>(); }>();
const q = useQuasar(); const q = useQuasar();
const emit = defineEmits<{ 'onGetComponentHeight': [value: number] }>(); const emit = defineEmits<{ 'onGetComponentHeight': [value: number] }>();
const selfRef = ref<HTMLElement | null>(null); const selfRef = ref<HTMLElement | null>(null);
const date_format_options = computed(() => q.platform.is.mobile ? { day: 'numeric', month: 'short', year: 'numeric' } : { day: 'numeric', month: 'long', year: 'numeric', }); const date_format_options = computed(() =>
q.platform.is.mobile
? { day: 'numeric', month: 'short', year: 'numeric' }
: { day: 'numeric', month: 'long', year: 'numeric' }
);
onUpdated(() => { onUpdated(() => { if (selfRef.value) emit('onGetComponentHeight', selfRef.value.offsetHeight); });
if (selfRef.value) { onMounted(() => { if (selfRef.value) emit('onGetComponentHeight', selfRef.value.offsetHeight); });
emit('onGetComponentHeight', selfRef.value.offsetHeight);
}
});
onMounted(() => {
if (selfRef.value) {
emit('onGetComponentHeight', selfRef.value.offsetHeight);
}
})
</script> </script>
<template> <template>
<div <div
ref="selfRef" ref="selfRef"
class="column text-uppercase text-center text-weight-bolder text-h4 q-pt-md" class="column text-center q-pt-md q-pb-xs"
> >
<!-- <span
v-if="!$q.platform.is.mobile"
class="col q-pt-lg"
>
{{ $t(title) }}
</span> -->
<transition <transition
enter-active-class="animated fadeInDown" enter-active-class="animated fadeInDown"
leave-active-class="animated fadeOutDown" leave-active-class="animated fadeOutDown"
@ -53,19 +38,36 @@
<div <div
:key="startDate" :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"
:class="$q.platform.is.mobile ? 'q-my-sm' : ''"
> >
<div class="text-accent text-weight-bold text-h6"> <span class="period-date">
{{ $d(date.extractDate(startDate, 'YYYY-MM-DD'), date_format_options) }} {{ $d(date.extractDate(startDate, 'YYYY-MM-DD'), date_format_options) }}
</div> </span>
<div class="text-body2 q-mx-md text-weight-medium"> <span class="period-sep">
{{ $t('shared.misc.to') }} {{ $t('shared.misc.to') }}
</div> </span>
<div class="text-accent text-weight-bold text-h6"> <span class="period-date">
{{ $d(date.extractDate(endDate, 'YYYY-MM-DD'), date_format_options) }} {{ $d(date.extractDate(endDate, 'YYYY-MM-DD'), date_format_options) }}
</div> </span>
</div> </div>
</transition> </transition>
</div> </div>
</template> </template>
<style scoped>
.period-date {
font-size: 1.1rem;
font-weight: 700;
text-transform: uppercase;
color: var(--q-accent);
letter-spacing: 0.02em;
}
.period-sep {
font-size: 0.8rem;
font-weight: 500;
color: rgba(128, 128, 128, 0.7);
margin: 0 12px;
text-transform: lowercase;
}
</style>

View File

@ -45,52 +45,45 @@
</script> </script>
<template> <template>
<div class="row"> <div class="nav-group row items-center no-wrap">
<!-- navigation to previous week -->
<q-btn <q-btn
push flat
rounded dense
icon="keyboard_arrow_left" round
icon="o_chevron_left"
color="accent" color="accent"
@click="getPreviousPayPeriod" size="md"
:disable="is_previous_pay_period_limit || timesheet_store.is_loading || is_disabled" :disable="is_previous_pay_period_limit || timesheet_store.is_loading || is_disabled"
class="q-mr-sm q-px-sm" @click="getPreviousPayPeriod"
>
<q-tooltip
anchor="top middle"
self="center middle"
class="bg-primary text-uppercase text-weight-bold"
> >
<q-tooltip anchor="top middle" self="center middle" class="bg-primary text-uppercase text-weight-bold">
{{ $t('timesheet.nav_button.previous_week') }} {{ $t('timesheet.nav_button.previous_week') }}
</q-tooltip> </q-tooltip>
</q-btn> </q-btn>
<!-- navigation through calendar date picker -->
<q-btn <q-btn
push flat
rounded dense
icon="calendar_month" no-caps
icon="o_calendar_month"
color="accent" color="accent"
@click="is_showing_calendar_picker = !is_showing_calendar_picker" size="md"
:disable="timesheet_store.is_loading || is_disabled" :disable="timesheet_store.is_loading || is_disabled"
:class="$q.platform.is.mobile ? 'q-px-md' : 'q-px-xl'" class="q-mx-xs"
> @click="is_showing_calendar_picker = !is_showing_calendar_picker"
<q-tooltip
anchor="top middle"
self="center middle"
class="bg-primary text-uppercase text-weight-bold"
> >
<q-tooltip anchor="top middle" self="center middle" class="bg-primary text-uppercase text-weight-bold">
{{ $t('timesheet.nav_button.calendar_date_picker') }} {{ $t('timesheet.nav_button.calendar_date_picker') }}
</q-tooltip> </q-tooltip>
<!-- date picker calendar -->
<q-menu <q-menu
v-model="is_showing_calendar_picker" v-model="is_showing_calendar_picker"
no-parent-event no-parent-event
anchor="bottom middle" anchor="bottom middle"
self="top middle" self="top middle"
:offset="[0, 10]" :offset="[0, 8]"
class="shadow-24" class="shadow-24"
style="border-radius: 12px; overflow: hidden;"
> >
<q-date <q-date
v-model="calendar_date" v-model="calendar_date"
@ -104,22 +97,28 @@
</q-menu> </q-menu>
</q-btn> </q-btn>
<!-- navigation to next week -->
<q-btn <q-btn
push flat
rounded dense
icon="keyboard_arrow_right" round
icon="o_chevron_right"
color="accent" color="accent"
@click="getNextPayPeriod" size="md"
:disable="timesheet_store.is_loading || is_disabled" :disable="timesheet_store.is_loading || is_disabled"
class="q-ml-sm q-px-sm" @click="getNextPayPeriod"
> >
<q-tooltip <q-tooltip anchor="top middle" self="center middle" class="bg-primary text-uppercase text-weight-bold">
anchor="top middle" {{ $t('timesheet.nav_button.next_week') }}
self="center middle"
class="bg-primary text-uppercase text-weight-bold"
> {{ $t('timesheet.nav_button.next_week') }}
</q-tooltip> </q-tooltip>
</q-btn> </q-btn>
</div> </div>
</template> </template>
<style scoped>
.nav-group {
background: rgba(14, 165, 80, 0.06);
border: 1px solid rgba(14, 165, 80, 0.15);
border-radius: 10px;
padding: 2px 4px;
}
</style>

View File

@ -14,6 +14,8 @@
import type { TimesheetDay } from 'src/modules/timesheets/models/timesheet.models'; import type { TimesheetDay } from 'src/modules/timesheets/models/timesheet.models';
import { isShiftOverlap } from 'src/modules/timesheets/utils/shift.util'; import { isShiftOverlap } from 'src/modules/timesheets/utils/shift.util';
const CURRENT_DATE_STRING = new Date().toISOString().slice(0, 10);
const { locale } = useI18n(); const { locale } = useI18n();
const uiStore = useUiStore(); const uiStore = useUiStore();
const shiftApi = useShiftApi(); const shiftApi = useShiftApi();
@ -21,30 +23,32 @@ import { isShiftOverlap } from 'src/modules/timesheets/utils/shift.util';
const day = defineModel<TimesheetDay>({ required: true }); const day = defineModel<TimesheetDay>({ required: true });
const { isTimesheetApproved = false } = defineProps<{ const { timesheetId, isTimesheetApproved = false } = defineProps<{
timesheetId: number; timesheetId: number;
isTimesheetApproved?: boolean; isTimesheetApproved?: boolean;
}>(); }>();
const isDayApproved = computed(() => day.value.shifts.every(shift => shift.is_approved) && day.value.shifts.length > 1); const isDayApproved = computed(() => day.value.shifts.every(shift => shift.is_approved) && day.value.shifts.length > 0);
const isToday = computed(() => CURRENT_DATE_STRING === day.value.date);
const isWeekend = computed(() => {
const d = date.extractDate(day.value.date, 'YYYY-MM-DD');
return d.getDay() === 0 || d.getDay() === 6;
});
const isHoliday = computed(() => timesheetStore.federal_holidays.some(h => h.date === day.value.date));
const canEdit = computed(() => !isDayApproved.value && !isTimesheetApproved);
const addNewShift = (day_shifts: Shift[], date: string, timesheet_id: number) => { const addNewShift = () => {
if (!canEdit.value) return;
uiStore.focusNextComponent = true; uiStore.focusNextComponent = true;
const newShift = new Shift; const newShift = new Shift(day.value.date);
newShift.date = date; newShift.timesheet_id = timesheetId;
newShift.timesheet_id = timesheet_id; day.value.shifts.push(newShift);
day_shifts.push(newShift);
}; };
const getHolidayName = (date: string) => { const getHolidayName = (_date: string) => {
const holiday = timesheetStore.federal_holidays.find(holiday => holiday.date === date); const holiday = timesheetStore.federal_holidays.find(h => h.date === _date);
if (!holiday) return; if (!holiday) return;
return locale.value === 'fr-FR' ? holiday.nameFr : holiday.nameEn;
if (locale.value === 'fr-FR')
return holiday.nameFr;
else if (locale.value === 'en-CA')
return holiday.nameEn;
}; };
const onTimeFieldBlur = () => { const onTimeFieldBlur = () => {
@ -58,58 +62,65 @@ import { isShiftOverlap } from 'src/modules/timesheets/utils/shift.util';
else else
await shiftApi.deleteShiftById(shiftId); await shiftApi.deleteShiftById(shiftId);
if (day.value.shifts.length < 2) { if (day.value.shifts.length < 2) onTimeFieldBlur();
onTimeFieldBlur();
}
}; };
</script> </script>
<template> <template>
<div class="row q-pa-sm full-width relative-position"> <div class="full-width q-px-sm">
<!-- Holiday label -->
<!-- optional label indicating which holiday if today is a holiday --> <div v-if="isHoliday" class="holiday-label">
<span <q-icon name="o_celebration" size="xs" color="purple-5" class="q-mr-xs" />
v-if="timesheetStore.federal_holidays.some(holiday => holiday.date === day.date)"
class="absolute-top-left text-uppercase text-weight-bolder text-purple-5"
style="transform: translate(25px, -7px);"
>
{{ getHolidayName(day.date) }} {{ getHolidayName(day.date) }}
</span>
<!-- mobile version in portrait mode -->
<div
v-if="$q.platform.is.mobile && ($q.screen.width < $q.screen.height)"
class="col-auto full-width q-px-md q-py-sm"
>
<div
class="shadow-12 rounded-10"
:class="(isDayApproved || isTimesheetApproved) ? 'bg-accent' : 'bg-dark'"
>
<div
class="text-weight-bolder text-uppercase text-h6 q-py-sm text-center relative-position"
:class="(isDayApproved || isTimesheetApproved) ? 'bg-accent rounded-10' : 'bg-primary'"
style="line-height: 1em; border-radius: 10px 10px 0 0;"
>
<span class="text-white">
{{ $d(date.extractDate(day.date, 'YYYY-MM-DD'), {
weekday: 'long', day: 'numeric', month: 'long'
}) }}
</span>
<q-icon
v-if="(isDayApproved || isTimesheetApproved)"
name="verified"
size="3em"
color="white"
class="absolute-top-left z-top"
style="top: -0.2em; left: 0px;"
/>
</div> </div>
<div <div
v-if="day.shifts.filter(shift => shift.id !== 0).length > 0" class="mobile-day-card"
class="q-pa-none transparent" :class="[
isToday ? 'mobile-day-today' : '',
isWeekend ? 'mobile-day-weekend' : '',
isHoliday ? 'mobile-day-holiday' : '',
(isDayApproved || isTimesheetApproved) ? 'mobile-day-approved' : '',
]"
@dblclick="canEdit && day.shifts.length === 0 && addNewShift()"
> >
<!-- Day header -->
<div
class="mobile-day-header"
:class="(isDayApproved || isTimesheetApproved) ? 'bg-accent' : (isHoliday ? 'bg-purple-5' : '')"
>
<div class="row items-center no-wrap">
<span class="mobile-day-date">{{ date.extractDate(day.date, 'YYYY-MM-DD').getDate() }}</span>
<div class="column q-ml-sm">
<span class="mobile-day-weekday">
{{ $d(date.extractDate(day.date, 'YYYY-MM-DD'), { weekday: 'long' }) }}
</span>
<span class="mobile-day-month">
{{ $d(date.extractDate(day.date, 'YYYY-MM-DD'), { month: 'long' }) }}
</span>
</div>
<q-space />
<q-icon
v-if="(isDayApproved || isTimesheetApproved)"
name="o_verified"
size="sm"
color="white"
/>
<q-btn
v-else-if="canEdit"
flat
round
dense
icon="o_more_time"
:color="(isDayApproved || isTimesheetApproved) ? 'white' : 'accent'"
size="md"
@click="addNewShift"
/>
</div>
</div>
<!-- Shifts -->
<div v-if="day.shifts.length > 0" class="mobile-day-shifts">
<div <div
v-for="shift, shiftIndex in day.shifts" v-for="shift, shiftIndex in day.shifts"
:key="shiftIndex" :key="shiftIndex"
@ -118,25 +129,119 @@ import { isShiftOverlap } from 'src/modules/timesheets/utils/shift.util';
v-model:shift="day.shifts[shiftIndex]!" v-model:shift="day.shifts[shiftIndex]!"
:current-shifts="day.shifts" :current-shifts="day.shifts"
:has-shift-after="shiftIndex < day.shifts.length - 1" :has-shift-after="shiftIndex < day.shifts.length - 1"
:is-holiday="isHoliday"
@request-delete="deleteCurrentShift(shift.id, shiftIndex)" @request-delete="deleteCurrentShift(shift.id, shiftIndex)"
@on-time-field-blur="onTimeFieldBlur()"
/> />
</div> </div>
</div> </div>
<div class="q-pa-none"> <!-- Empty state -->
<q-btn <div
v-if="!(isDayApproved || isTimesheetApproved)" v-else-if="canEdit"
square class="mobile-day-empty"
dense >
size="xl" <span class="mobile-day-empty-hint">Double-tap pour ajouter</span>
color="accent"
icon="more_time"
class="full-width"
style="border-radius: 0 0 10px 10px;"
@click="addNewShift(day.shifts, day.date, timesheetId)"
/>
</div>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<style scoped lang="scss">
.holiday-label {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
color: #ab47bc;
padding: 0 4px 2px;
display: flex;
align-items: center;
}
.mobile-day-card {
border-radius: 12px;
overflow: hidden;
background: var(--q-dark);
box-shadow: 0 1px 6px rgba(0,0,0,0.08);
transition: box-shadow 0.15s;
}
.mobile-day-today {
box-shadow: 0 0 0 2.5px var(--q-accent), 0 4px 16px rgba(14, 165, 80, 0.2) !important;
}
.mobile-day-weekend {
border-left: 3px solid #c8ccd3;
background: #eceef2 !important;
}
:global(body.body--dark) .mobile-day-weekend {
border-left-color: #3a3a4a;
background: #22222e !important;
}
.mobile-day-holiday {
border: 2px solid #ab47bc;
}
.mobile-day-approved {
.mobile-day-header {
background: var(--q-accent);
}
}
.mobile-day-header {
padding: 10px 14px;
background: rgba(0,0,0,0.03);
}
:global(body.body--dark) .mobile-day-header {
background: rgba(255,255,255,0.04);
}
.mobile-day-date {
font-size: 1.8rem;
font-weight: 800;
line-height: 1;
color: var(--q-accent);
}
.mobile-day-approved .mobile-day-date {
color: white;
}
.mobile-day-weekday {
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.mobile-day-month {
font-size: 0.65rem;
text-transform: uppercase;
opacity: 0.6;
}
.mobile-day-approved .mobile-day-weekday,
.mobile-day-approved .mobile-day-month {
color: white;
}
.mobile-day-shifts {
padding: 4px 0;
}
.mobile-day-empty {
padding: 12px 16px;
text-align: center;
}
.mobile-day-empty-hint {
font-size: 0.7rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.03em;
color: rgba(128,128,128,0.35);
}
</style>

View File

@ -112,7 +112,7 @@
<template> <template>
<div class="column"> <div class="column">
<div class="row q-pa-sm"> <div class="column q-pa-sm">
<div class="col column"> <div class="col column">
<div class="row justify-center q-pb-xs q-px-sm full-width"> <div class="row justify-center q-pb-xs q-px-sm full-width">
<!-- shift type --> <!-- shift type -->
@ -185,7 +185,7 @@
<template #after> <template #after>
<q-icon <q-icon
v-if="shift.is_approved" v-if="shift.is_approved"
:name="shift.is_remote ? 'las la-laptop' : 'las la-building'" :name="shift.is_remote ? 'o_laptop' : 'o_business'"
size="1.2em" size="1.2em"
color="accent" color="accent"
class="q-mr-sm" class="q-mr-sm"
@ -301,15 +301,20 @@
</div> </div>
</div> </div>
<div class="col-auto"> <!-- Delete button at bottom of card -->
<q-btn <div
v-if="!shift.is_approved" v-if="!shift.is_approved"
outline class="row justify-end q-px-sm q-pb-xs"
>
<q-btn
flat
dense dense
no-caps
color="negative" color="negative"
icon="las la-trash" icon="o_delete_outline"
size="lg" label="Supprimer"
class="full-height rounded-5" size="sm"
class="text-weight-medium"
@click="$emit('requestDelete')" @click="$emit('requestDelete')"
/> />
</div> </div>

View File

@ -12,6 +12,9 @@
const show_autofill = ref(false); const show_autofill = ref(false);
const getWeekHours = (ts: typeof timesheet_store.timesheets[0]) =>
ts.weekly_hours.regular + ts.weekly_hours.evening + ts.weekly_hours.emergency + ts.weekly_hours.overtime;
const onClickApplyPreset = async (timesheet_id: number) => { const onClickApplyPreset = async (timesheet_id: number) => {
show_autofill.value = false; show_autofill.value = false;
await timesheet_api.applyPreset(timesheet_id); await timesheet_api.applyPreset(timesheet_id);
@ -22,80 +25,49 @@
<div <div
v-if="$q.platform.is.mobile && $q.screen.width < $q.screen.height" v-if="$q.platform.is.mobile && $q.screen.width < $q.screen.height"
class="row items-start q-px-sm q-pt-sm full-width" class="row items-start q-px-sm q-pt-sm full-width"
style="gap: 8px;"
> >
<!-- per timesheet -->
<div <div
v-for="timesheet, timesheet_index in timesheet_store.timesheets" v-for="timesheet, ti in timesheet_store.timesheets"
:key="timesheet_index" :key="ti"
class="col column flex-center q-pa-sm" class="col column"
> >
<!-- container -->
<div <div
class="rounded-5 relative-position q-px-sm q-pt-sm q-pb-xs full-width shadow-4" class="mobile-week-card"
style="border: 1px solid var(--q-accent);"
@click="show_autofill = !show_autofill" @click="show_autofill = !show_autofill"
> >
<!-- icon to show preset is available --> <div class="row items-center no-wrap">
<q-icon <q-icon name="o_date_range" size="xs" color="accent" class="q-mr-xs" />
v-if="timesheet.days.every(day => day.shifts.length < 1)" <span class="mobile-week-label">Sem. {{ ti + 1 }}</span>
name="schedule_send" <q-space />
size="sm" <span class="mobile-week-hours">{{ getHoursMinutesStringFromHoursFloat(getWeekHours(timesheet)) }}</span>
color="accent"
class="absolute-top-right bg-secondary"
style="transform: translate(10px, -10px);"
/>
<!-- label for week number -->
<div
class="self-start text-uppercase text-weight-bolder text-overline text-accent bg-secondary absolute-top-left q-px-xs"
style="font-size: 1em; top: -7px; left: 10px; line-height: 1em;"
>
{{
getHoursMinutesStringFromHoursFloat(timesheet.weekly_hours.regular +
timesheet.weekly_hours.evening +
timesheet.weekly_hours.emergency +
timesheet.weekly_hours.overtime)
}}
</div> </div>
<!-- preview of current number of shifts --> <!-- Day dots -->
<div class="row q-mt-xs" style="gap: 3px;">
<div <div
class="col row flex-center" v-for="day, di in timesheet.days"
style="height: 20px;" :key="di"
class="mobile-week-dot"
:class="day.shifts.length > 0 ? (day.shifts.every(s => s.is_approved) ? 'dot-approved' : 'dot-filled') : 'dot-empty'"
> >
<div <q-icon v-if="day.shifts.every(s => s.is_approved) && day.shifts.length > 0" name="o_check" size="8px" color="white" />
v-for="day, day_index in timesheet.days"
:key="day_index"
class="col row flex-center"
>
<q-badge
:color="day.shifts.length > 0 ? (day.shifts.every(shift => shift.is_approved) ? 'accent shadow-2' : 'white shadow-2') : 'blue-grey-5'"
:class="day.shifts.length > 0 ? (day.shifts.every(shift => shift.is_approved) ? 'q-px-xs' : 'q-pa-sm') : ''"
:style="day.shifts.length > 0 ? '' : 'opacity: 0.5'"
>
<q-icon
v-if="day.shifts.every(shift => shift.is_approved) && day.shifts.length > 0"
name="check"
class="q-pa-none"
/>
</q-badge>
</div> </div>
</div> </div>
</div> </div>
<!-- button to apply weekly schedule preset --> <!-- Apply preset -->
<q-slide-transition> <q-slide-transition>
<div <div v-if="show_autofill && timesheet.days.every(d => d.shifts.length < 1)" class="q-pt-xs">
class="col-auto flex-center row q-pt-xs full-width"
v-if="show_autofill"
>
<q-btn <q-btn
v-if="timesheet.days.every(day => day.shifts.length < 1)" flat
push
dense dense
no-caps
color="accent" color="accent"
icon="o_schedule_send"
:label="$t('timesheet.apply_preset')" :label="$t('timesheet.apply_preset')"
class="full-width" class="full-width text-weight-bold"
style="border: 1px solid currentColor; border-radius: 8px;"
@click="onClickApplyPreset(timesheet.timesheet_id)" @click="onClickApplyPreset(timesheet.timesheet_id)"
/> />
</div> </div>
@ -103,3 +75,40 @@
</div> </div>
</div> </div>
</template> </template>
<style scoped>
.mobile-week-card {
background: rgba(14, 165, 80, 0.04);
border: 1px solid rgba(14, 165, 80, 0.2);
border-radius: 10px;
padding: 8px 12px;
}
.mobile-week-label {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
color: var(--q-accent);
}
.mobile-week-hours {
font-size: 0.85rem;
font-weight: 800;
color: var(--q-accent);
}
.mobile-week-dot {
flex: 1;
height: 6px;
border-radius: 3px;
display: flex;
align-items: center;
justify-content: center;
}
.dot-empty { background: rgba(0,0,0,0.08); }
.dot-filled { background: var(--q-accent); opacity: 0.5; }
.dot-approved { background: var(--q-accent); }
:global(body.body--dark) .dot-empty { background: rgba(255,255,255,0.1); }
</style>

View File

@ -27,13 +27,12 @@
class="column flex-center rounded-10 text-center self-center bg-transparent relative-position" class="column flex-center rounded-10 text-center self-center bg-transparent relative-position"
:style="date_box_size" :style="date_box_size"
> >
<!-- Today indicator: pure CSS circle -->
<div <div
v-if="today" v-if="today"
class="absolute fit q-px-sm q-py-md" class="absolute fit q-px-sm q-py-md"
> >
<div class="fit" style="background-image: url('src/assets/circle.png'); background-size: 100% 100%; background-repeat: no-repeat; opacity: 0.65;"> <div class="fit today-circle"></div>
</div>
</div> </div>
<span <span
@ -67,9 +66,10 @@
scoped scoped
lang="css" lang="css"
> >
.bordered-text { .today-circle {
text-shadow: 2px 0 white, -2px 0 white, 0 2px white, 0 -2px white, border-radius: 50%;
1px 1px white, -1px -1px white, 1px -1px white, -1px 1px white; border: 2.5px solid var(--q-accent);
font-size: 0.9em; opacity: 0.5;
background: radial-gradient(circle, rgba(14, 165, 80, 0.08) 0%, transparent 70%);
} }
</style> </style>

View File

@ -349,7 +349,7 @@
push push
dense dense
:color="shift.is_approved ? 'white' : (shift.comment ? 'accent' : (holiday ? 'purple-5' : 'blue-grey-5'))" :color="shift.is_approved ? 'white' : (shift.comment ? 'accent' : (holiday ? 'purple-5' : 'blue-grey-5'))"
:icon="shift.comment ? 'chat' : 'chat_bubble_outline'" :icon="shift.comment ? 'o_chat' : 'o_chat_bubble_outline'"
:text-color="shift.is_approved ? (holiday ? 'purple-5' : 'accent') : 'white'" :text-color="shift.is_approved ? (holiday ? 'purple-5' : 'accent') : 'white'"
class="col" class="col"
:class="$q.platform.is.mobile ? 'q-mt-xs bg-dark' : ''" :class="$q.platform.is.mobile ? 'q-mt-xs bg-dark' : ''"
@ -386,7 +386,7 @@
@keyup.enter="scope.set" @keyup.enter="scope.set"
> >
<template #append> <template #append>
<q-icon name="edit" /> <q-icon name="o_edit" />
</template> </template>
<template #counter> <template #counter>
@ -418,7 +418,7 @@
dense dense
:disable="shift.is_approved" :disable="shift.is_approved"
tabindex="-1" tabindex="-1"
icon="las la-trash" icon="o_delete_outline"
text-color="negative" text-color="negative"
class="col" class="col"
size="1.2em" size="1.2em"

View File

@ -49,15 +49,24 @@
const isToday = computed(() => CURRENT_DATE_STRING === day.value.date); const isToday = computed(() => CURRENT_DATE_STRING === day.value.date);
const isEmpty = computed(() => day.value.shifts.length === 0);
const canEdit = computed(() => !isDayApproved.value && !timesheetApproved);
// ================== Methods ================== // ================== Methods ==================
const addNewShift = () => { const addNewShift = () => {
if (!canEdit.value) return;
uiStore.focusNextComponent = true; uiStore.focusNextComponent = true;
const newShift = new Shift(day.value.date); const newShift = new Shift(day.value.date);
newShift.timesheet_id = timesheetId; newShift.timesheet_id = timesheetId;
day.value.shifts.push(newShift); day.value.shifts.push(newShift);
}; };
const onDblClickCard = () => {
if (isEmpty.value && canEdit.value) addNewShift();
};
const deleteCurrentShift = async (shiftId: number, index: number) => { const deleteCurrentShift = async (shiftId: number, index: number) => {
if (shiftId <= 0) if (shiftId <= 0)
day.value.shifts.splice(index, 1); day.value.shifts.splice(index, 1);
@ -79,7 +88,6 @@
} }
const onClickApplyDailyPreset = async () => { const onClickApplyDailyPreset = async () => {
console.log(timesheetId, weekDayIndex, day.value.date, employeeEmail);
await timesheetApi.applyPreset(timesheetId, weekDayIndex, day.value.date, employeeEmail); await timesheetApi.applyPreset(timesheetId, weekDayIndex, day.value.date, employeeEmail);
} }
@ -96,12 +104,20 @@
</script> </script>
<template> <template>
<div class="row fit rounded-10 ellipsis no-wrap shadow-6"> <div
class="row fit rounded-10 ellipsis no-wrap shift-day-row"
:data-date="day.date"
:class="[
isToday ? 'shift-today-glow shadow-10' : 'shadow-4',
isEmpty && canEdit ? 'shift-day-empty' : ''
]"
@dblclick="onDblClickCard"
>
<!-- optional label indicating which holiday if today is a holiday --> <!-- optional label indicating which holiday if today is a holiday -->
<span <span
v-if="isHoliday" v-if="isHoliday"
class="absolute-top-left text-uppercase text-bold holiday-border text-white" class="absolute-top-left text-uppercase text-bold holiday-border text-white"
style="transform: translate(25px, -2px);" style="transform: translate(25px, -2px); z-index: 1;"
> >
{{ getHolidayName(day.date) }} {{ getHolidayName(day.date) }}
</span> </span>
@ -129,31 +145,47 @@
@mouseenter="presetMouseover = true" @mouseenter="presetMouseover = true"
@mouseleave="presetMouseover = false" @mouseleave="presetMouseover = false"
> >
<!-- Button to apply preset to day --> <!-- Empty state: hint to double-click or use preset -->
<div
v-if="isEmpty && canEdit"
class="col row items-center justify-center empty-day-hint"
>
<!-- Preset button on hover -->
<transition <transition
appear appear
enter-active-class="animated zoomIn fast" enter-active-class="animated fadeIn fast"
leave-active-class="animated zoomOut fast" leave-active-class="animated fadeOut fast"
> >
<q-btn <q-btn
v-if="day.shifts.length < 1 && presetMouseover && timesheetStore.has_timesheet_preset" v-if="presetMouseover && timesheetStore.has_timesheet_preset"
:disable="day.shifts.length > 0"
flat flat
dense dense
size="lg" no-caps
:label="$t('timesheet.apply_preset_day')"
class="text-uppercase text-weight-bold text-accent q-mx-lg q-py-none rounded-5"
style="opacity: 0.6;"
@click.stop="onClickApplyDailyPreset"
>
<q-icon
name="las la-calendar-day"
color="accent"
size="md" size="md"
icon="o_calendar_month"
:label="$t('timesheet.apply_preset_day')"
class="text-accent q-mx-md rounded-5"
style="opacity: 0.7;"
@click.stop="onClickApplyDailyPreset"
/> />
</q-btn>
</transition> </transition>
<!-- Hint text on hover only -->
<transition
enter-active-class="animated fadeIn fast"
leave-active-class="animated fadeOut fast"
>
<span
v-if="presetMouseover && (!timesheetStore.has_timesheet_preset)"
class="empty-day-label"
>
<q-icon name="o_more_time" size="xs" class="q-mr-xs" />
Double-cliquer pour ajouter
</span>
</transition>
</div>
<!-- Shift rows -->
<div <div
v-for="shift, shiftIndex in day.shifts" v-for="shift, shiftIndex in day.shifts"
:key="shiftIndex" :key="shiftIndex"
@ -171,28 +203,35 @@
</div> </div>
</div> </div>
<div class="col-auto self-stretch"> <!-- Right action column -->
<div class="col-auto self-stretch row items-center">
<q-icon <q-icon
v-if="(isDayApproved || timesheetApproved)" v-if="(isDayApproved || timesheetApproved)"
name="verified" name="o_verified"
color="white" color="white"
size="xl" size="lg"
class="full-height" class="q-px-xs"
:class="(isDayApproved || timesheetApproved) ? (isHoliday ? 'bg-purple-5' : 'bg-accent') : ''"
/> />
<q-btn <q-btn
v-else v-else
:dense="!$q.platform.is.mobile" flat
square round
icon="more_time" icon="o_more_time"
size="lg" size="md"
:color="isHoliday ? 'purple-5' : 'accent'" :color="isHoliday ? 'purple-5' : 'accent'"
text-color="white" class="q-mx-xs add-shift-btn"
class="full-height"
:style="isHoliday ? 'border-radius: 0 6px 6px 0;' : ''"
@click="addNewShift" @click="addNewShift"
/> >
<q-tooltip
anchor="center left"
self="center right"
:offset="[8, 0]"
class="text-body2"
>
{{ $t('timesheet.shift.actions.add') || 'Ajouter un quart' }}
</q-tooltip>
</q-btn>
</div> </div>
</div> </div>
</div> </div>
@ -207,4 +246,25 @@
text-shadow: 2px 0 $purple-5, -2px 0 $purple-5, 0 2px $purple-5, 0 -2px $purple-5, text-shadow: 2px 0 $purple-5, -2px 0 $purple-5, 0 2px $purple-5, 0 -2px $purple-5,
1px 1px $purple-5, -1px -1px $purple-5, 1px -1px $purple-5, -1px 1px $purple-5; 1px 1px $purple-5, -1px -1px $purple-5, 1px -1px $purple-5, -1px 1px $purple-5;
} }
.empty-day-hint {
min-height: 40px;
cursor: pointer;
}
.empty-day-label {
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.03em;
color: rgba(255, 255, 255, 0.25);
display: flex;
align-items: center;
user-select: none;
transition: color 0.15s;
}
.shift-day-empty:hover .empty-day-label {
color: rgba(255, 255, 255, 0.5);
}
</style> </style>

View File

@ -5,24 +5,20 @@
import ShiftList from 'src/modules/timesheets/components/shift-list.vue'; import ShiftList from 'src/modules/timesheets/components/shift-list.vue';
import ShiftListMobile from 'src/modules/timesheets/components/mobile/shift-list-mobile.vue'; import ShiftListMobile from 'src/modules/timesheets/components/mobile/shift-list-mobile.vue';
import { computed, ref } from 'vue'; import { ref } from 'vue';
import { useTimesheetStore } from 'src/stores/timesheet-store'; import { useTimesheetStore } from 'src/stores/timesheet-store';
import { useTimesheetApi } from 'src/modules/timesheets/composables/use-timesheet-api'; import { useTimesheetApi } from 'src/modules/timesheets/composables/use-timesheet-api';
import type { QScrollArea, TouchSwipeValue } from 'quasar'; import type { TouchSwipeValue } from 'quasar';
const timesheet_store = useTimesheetStore(); const timesheet_store = useTimesheetStore();
const timesheet_api = useTimesheetApi(); const timesheet_api = useTimesheetApi();
const { mode = 'normal' } = defineProps<{ const { mode: _mode = 'normal' } = defineProps<{
mode: 'normal' | 'approval'; mode: 'normal' | 'approval';
}>(); }>();
const mobile_animation_direction = ref('fadeInLeft'); const mobile_animation_direction = ref('fadeInLeft');
const timesheet_page = ref<QScrollArea | null>(null);
const scroll_y = computed(() => timesheet_page.value?.getScrollPosition().top ?? 0);
const handleSwipe: TouchSwipeValue = (details) => { const handleSwipe: TouchSwipeValue = (details) => {
mobile_animation_direction.value = details.direction === 'left' ? 'fadeInRight' : 'fadeInLeft'; mobile_animation_direction.value = details.direction === 'left' ? 'fadeInRight' : 'fadeInLeft';
if (details.distance && details.distance.x && Math.abs(details.distance.x) > 15) { if (details.distance && details.distance.x && Math.abs(details.distance.x) > 15) {
@ -31,71 +27,41 @@
}; };
const onTodayComponentFound = (today_component: HTMLElement | undefined) => { const onTodayComponentFound = (today_component: HTMLElement | undefined) => {
if (timesheet_page.value && today_component) if (today_component) {
timesheet_page.value.setScrollPosition('vertical', today_component.offsetTop, 800); today_component.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
} }
</script> </script>
<template> <template>
<div <div
class="column fit relative-position" class="column full-width"
:style="$q.platform.is.mobile && $q.screen.width < $q.screen.height ? 'margin-bottom: 40px' : ''" :style="$q.platform.is.mobile && $q.screen.width < $q.screen.height ? 'margin-bottom: 40px' : ''"
v-touch-swipe.horizontal="handleSwipe" v-touch-swipe.horizontal="handleSwipe"
> >
<q-scroll-area <!-- Show if no timesheets found -->
ref="timesheet_page"
:horizontal-offset="[0, 3]"
class="col absolute-full hide-scrollbar"
:thumb-style="{ opacity: '0' }"
:bar-style="{ opacity: '0' }"
>
<!-- Show if no timesheets found (further than one month from present) -->
<div <div
v-if="timesheet_store.timesheets.length < 1 && !timesheet_store.is_loading" v-if="timesheet_store.timesheets.length < 1 && !timesheet_store.is_loading"
class="col-auto column flex-center fit q-py-lg" class="column flex-center q-py-xl"
style="min-height: 20vh;" style="min-height: 20vh;"
> >
<span class="text-uppercase text-weight-bolder text-center">{{ $t('shared.error.no_data_found') <span class="text-uppercase text-weight-bolder text-center">{{ $t('shared.error.no_data_found') }}</span>
}}</span>
<q-icon <q-icon
name="las la-calendar" name="o_event_busy"
color="accent" color="accent"
size="10em" size="8em"
class="absolute" class="absolute"
style="opacity: 0.2;" style="opacity: 0.15;"
/> />
</div> </div>
<!-- Else show timesheets if found --> <!-- Mobile -->
<ShiftListMobile <ShiftListMobile
v-else-if="$q.platform.is.mobile" v-else-if="$q.platform.is.mobile"
@on-current-day-component-found="onTodayComponentFound" @on-current-day-component-found="onTodayComponentFound"
/> />
<!-- Desktop: no scroll area, page scrolls naturally -->
<ShiftList v-else /> <ShiftList v-else />
</q-scroll-area>
<q-page-sticky
v-if="mode === 'normal'"
position="bottom-right"
:offset="$q.screen.width > $q.screen.height ? [15, 15] : [15, 65]"
class="z-top"
>
<transition
appear
enter-active-class="animated zoomIn"
leave-active-class="animated zoomOut"
>
<q-btn
v-if="scroll_y > 400"
fab
icon="las la-chevron-up"
color="white"
text-color="accent"
class="shadow-12"
@click="timesheet_page!.setScrollPosition('vertical', 0, 300)"
/>
</transition>
</q-page-sticky>
</div> </div>
</template> </template>

View File

@ -3,9 +3,10 @@
lang="ts" lang="ts"
> >
import { computed, onMounted } from 'vue'; import { computed, onMounted } from 'vue';
import { date } from 'quasar';
import { useAuthStore } from 'src/stores/auth-store'; import { useAuthStore } from 'src/stores/auth-store';
import { useTimesheetStore } from 'src/stores/timesheet-store'; import { useTimesheetStore } from 'src/stores/timesheet-store';
import { getHoursMinutesStringFromHoursFloat } from 'src/utils/date-and-time-utils'; import { getHoursMinutesStringFromHoursFloat, getHoursMinutesBetweenTwoHHmm } from 'src/utils/date-and-time-utils';
const { mode = 'totals', timesheetMode = 'normal', totalHours = 0, totalExpenses = 0 } = defineProps<{ const { mode = 'totals', timesheetMode = 'normal', totalHours = 0, totalExpenses = 0 } = defineProps<{
mode: 'total-hours' | 'off-hours'; mode: 'total-hours' | 'off-hours';
@ -23,6 +24,35 @@
const sickHours = computed(() => timesheetStore.paid_time_off_totals.sick_hours); const sickHours = computed(() => timesheetStore.paid_time_off_totals.sick_hours);
const bankedHours = computed(() => timesheetStore.paid_time_off_totals.banked_hours); const bankedHours = computed(() => timesheetStore.paid_time_off_totals.banked_hours);
const _hoursPercent = computed(() => Math.min(100, (totalHours / 80) * 100));
// Day-by-day hours for each week
const emit = defineEmits<{ 'day-click': [dateStr: string] }>();
const DAY_ABBR = ['D', 'L', 'M', 'M', 'J', 'V', 'S'];
const weekDayHours = computed(() => {
return timesheetStore.timesheets.map(ts =>
ts.days.map(day => {
const hours = day.shifts.reduce((sum, shift) => {
if (!shift.end_time || !shift.start_time) return sum;
const t = getHoursMinutesBetweenTwoHHmm(shift.start_time, shift.end_time);
return sum + t.hours + t.minutes / 60;
}, 0);
const d = date.extractDate(day.date, 'YYYY-MM-DD');
const dow = d.getDay();
const isWeekend = dow === 0 || dow === 6;
return {
hours, isWeekend, dateStr: day.date,
dayAbbr: DAY_ABBR[dow],
dayNum: d.getDate(),
hasShifts: day.shifts.length > 0,
isApproved: day.shifts.length > 0 && day.shifts.every(s => s.is_approved),
};
})
);
});
onMounted(async () => { onMounted(async () => {
if (timesheetMode === 'normal') if (timesheetMode === 'normal')
await timesheetStore.getPaidTimeOffTotalsWithOptionalEmployeeEmail(); await timesheetStore.getPaidTimeOffTotalsWithOptionalEmployeeEmail();
@ -30,82 +60,317 @@
</script> </script>
<template> <template>
<div <div class="overview-card column full-width q-pa-md">
class="column full-width shadow-4 rounded-5 q-pa-sm" <!-- TOTAL HOURS MODE -->
style="border: 1px solid var(--q-accent);" <template v-if="mode === 'total-hours'">
> <!-- Header: total + expenses inline -->
<div <div class="overview-header row items-center q-mb-sm">
v-if="mode === 'total-hours'" <q-icon name="o_schedule" size="sm" color="accent" class="q-mr-sm" />
class="col column full-width" <span class="text-uppercase text-weight-bold text-body2">{{ $t('timesheet.total_hours') }}</span>
> <q-space />
<div <span class="text-h5 text-weight-bolder text-accent">{{ getHoursMinutesStringFromHoursFloat(totalHours) }}</span>
v-for="hours, index in weeklyHours" <span v-if="totalExpenses > 0" class="text-caption q-ml-md" style="opacity:0.6">
:key="index" <q-icon name="o_receipt_long" size="xs" /> {{ totalExpenses }}$
class="col row full-width"
>
<span class="col-auto text-uppercase text-caption text-bold text-accent">
{{ $t(`timesheet_approvals.table.weekly_hours_${index + 1}`) }}:
</span> </span>
<span class="col text-right">{{ getHoursMinutesStringFromHoursFloat(hours) }}</span>
</div> </div>
<div class="col row full-width"> <!-- Day bars per week -->
<span class="col-auto text-uppercase text-caption text-bold text-accent"> <div class="column" style="gap: 4px;">
{{ $t('timesheet.total_hours') }} <!-- Day abbreviation header (only on first week) -->
</span> <div class="overview-week-row" v-if="weekDayHours.length > 0">
<span class="overview-week-label"></span>
<span class="col text-right">{{ getHoursMinutesStringFromHoursFloat(totalHours) }}</span> <div class="overview-day-bars">
</div> <span
v-for="(day, di) in weekDayHours[0]"
<div class="col row full-width"> :key="'h'+di"
<span class="col-auto text-uppercase text-caption text-bold text-accent"> class="overview-day-header"
{{ $t('timesheet.total_expenses') }} :class="day.isWeekend ? 'day-hdr-weekend' : ''"
</span> >{{ day.dayAbbr }}</span>
<span class="col text-right">{{ totalExpenses }}$</span>
</div>
</div>
<div
v-else
class="col column full-width"
>
<div class="col row full-width">
<span class="col-auto text-uppercase text-caption text-bold text-accent">
{{ $t('timesheet.vacation_available') }}
</span>
<div class="col row">
<span class="col text-right">{{ getHoursMinutesStringFromHoursFloat(vacationHours) }}</span>
<span class="col-auto text-right q-pl-xs">{{ ' (' + Math.floor(vacationHours / 8) }}</span>
<span class="col-auto q-pl-xs">{{ $t('shared.label.day') }}{{ (Math.floor(vacationHours / 8) !== 1 ?
's' : '') + ')' }}</span>
</div> </div>
<span class="overview-week-hours"></span>
</div> </div>
<div <div
v-if="is_management" v-for="(week, wi) in weekDayHours"
class="col row full-width" :key="wi"
class="overview-week-row"
> >
<span class="col-auto text-uppercase text-caption text-bold text-accent"> <span class="overview-week-label">S{{ wi + 1 }}</span>
{{ $t('timesheet.sick_available') }} <div class="overview-day-bars">
</span>
<span class="col text-right">{{ getHoursMinutesStringFromHoursFloat(sickHours) }}</span>
</div>
<div <div
v-if="timesheetMode === 'normal'" v-for="(day, di) in week"
class="col row full-width" :key="di"
class="overview-day-bar"
:class="[
day.isWeekend ? 'bar-weekend' : '',
]"
:title="day.dayNum + ' — ' + getHoursMinutesStringFromHoursFloat(day.hours)"
@click="emit('day-click', day.dateStr)"
> >
<span class="col-auto text-uppercase text-caption text-bold text-accent"> <div
{{ $t('timesheet.banked_available') }} class="overview-day-bar-fill"
</span> :style="{ height: Math.min(100, (day.hours / 8) * 100) + '%' }"
:class="day.hasShifts ? (day.isApproved ? 'fill-approved' : 'fill-active') : 'fill-empty'"
<span class="col text-right">{{ getHoursMinutesStringFromHoursFloat(bankedHours) }}</span> ></div>
</div> </div>
</div> </div>
<span class="overview-week-hours">{{ getHoursMinutesStringFromHoursFloat(weeklyHours?.[wi] ?? 0) }}</span>
</div>
</div> </div>
</template> </template>
<!-- OFF HOURS MODE (PTO) -->
<template v-else>
<table class="pto-table">
<tr>
<td class="pto-icon"><q-icon name="o_flight_takeoff" size="xs" color="deep-orange-5" /></td>
<td class="pto-label">{{ $t('timesheet.vacation_available') }}</td>
<td class="pto-value">{{ getHoursMinutesStringFromHoursFloat(vacationHours) }}</td>
<td class="pto-extra">({{ Math.floor(vacationHours / 8) }}j)</td>
</tr>
<tr v-if="is_management">
<td class="pto-icon"><q-icon name="o_medical_services" size="xs" color="light-blue-6" /></td>
<td class="pto-label">{{ $t('timesheet.sick_available') }}</td>
<td class="pto-value">{{ getHoursMinutesStringFromHoursFloat(sickHours) }}</td>
<td class="pto-extra"></td>
</tr>
<tr v-if="timesheetMode === 'normal'">
<td class="pto-icon"><q-icon name="o_account_balance_wallet" size="xs" color="amber-8" /></td>
<td class="pto-label">{{ $t('timesheet.banked_available') }}</td>
<td class="pto-value">{{ getHoursMinutesStringFromHoursFloat(bankedHours) }}</td>
<td class="pto-extra"></td>
</tr>
</table>
</template>
</div>
</template>
<style scoped lang="scss">
.overview-card {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(14, 165, 80, 0.2);
border-radius: 12px;
transition: border-color 0.2s;
&:hover { border-color: rgba(14, 165, 80, 0.4); }
}
body.body--light .overview-card {
background: #fff;
border-color: rgba(14, 165, 80, 0.15);
box-shadow: 0 1px 8px rgba(0,0,0,0.04);
}
.overview-header {
padding-bottom: 4px;
border-bottom: 1px solid rgba(255,255,255,0.06);
}
body.body--light .overview-header {
border-bottom-color: rgba(0,0,0,0.06);
}
.overview-bar {
height: 4px;
border-radius: 2px;
background: rgba(255,255,255,0.08);
overflow: hidden;
}
body.body--light .overview-bar {
background: rgba(0,0,0,0.06);
}
.overview-bar-fill {
height: 100%;
border-radius: 2px;
background: var(--q-accent);
transition: width 0.5s ease;
}
.overview-rows {
display: flex;
flex-direction: column;
gap: 2px;
}
.overview-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 0;
}
.overview-row-sep {
border-top: 1px solid rgba(255,255,255,0.06);
margin-top: 2px;
padding-top: 6px;
}
body.body--light .overview-row-sep {
border-top-color: rgba(0,0,0,0.06);
}
.overview-week-row {
display: flex;
align-items: center;
gap: 8px;
}
.overview-week-label {
font-size: 0.65rem;
font-weight: 700;
color: var(--q-accent);
text-transform: uppercase;
min-width: 20px;
}
.overview-week-hours {
font-size: 0.75rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
min-width: 30px;
text-align: right;
}
.overview-day-bars {
flex: 1;
display: flex;
gap: 3px;
height: 20px;
align-items: flex-end;
}
.overview-day-header {
flex: 1;
text-align: center;
font-size: 0.55rem;
font-weight: 700;
text-transform: uppercase;
color: #888;
line-height: 1;
}
.day-hdr-weekend {
color: #bbb;
}
.overview-day-bar {
flex: 1;
height: 100%;
background: rgba(0,0,0,0.04);
border-radius: 3px;
display: flex;
align-items: flex-end;
overflow: hidden;
cursor: pointer;
transition: transform 0.1s;
}
.overview-day-bar:hover {
transform: scaleY(1.15);
box-shadow: 0 0 0 1.5px var(--q-accent);
border-radius: 3px;
}
body.body--dark .overview-day-bar {
background: rgba(255,255,255,0.06);
}
.overview-day-bar.bar-weekend {
background: rgba(0,0,0,0.02);
}
body.body--dark .overview-day-bar.bar-weekend {
background: rgba(255,255,255,0.03);
}
.overview-day-bar-fill {
width: 100%;
border-radius: 3px;
transition: height 0.4s ease;
}
.fill-empty {
background: transparent;
}
.fill-active {
background: var(--q-accent);
opacity: 0.6;
}
.fill-approved {
background: var(--q-accent);
}
.overview-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.02em;
color: var(--q-accent);
display: flex;
align-items: center;
}
/* PTO table */
.pto-table {
width: 100%;
border-collapse: collapse;
}
.pto-table tr {
border-bottom: 1px solid rgba(0,0,0,0.04);
}
:global(body.body--dark) .pto-table tr {
border-bottom-color: rgba(255,255,255,0.06);
}
.pto-table tr:last-child {
border-bottom: none;
}
.pto-icon {
width: 24px;
padding: 6px 4px 6px 0;
vertical-align: middle;
}
.pto-label {
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.02em;
color: #1e1e2a;
padding: 6px 8px 6px 0;
}
:global(body.body--dark) .pto-label {
color: #ddd;
}
.pto-value {
font-size: 0.85rem;
font-weight: 800;
text-align: right;
padding: 6px 0;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.pto-extra {
font-size: 0.7rem;
font-weight: 500;
color: #888;
padding: 6px 0 6px 4px;
text-align: left;
white-space: nowrap;
}
.overview-value {
font-size: 0.85rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
</style>

View File

@ -24,6 +24,8 @@
const currentDayComponentWatcher = ref(currentDayComponent); const currentDayComponentWatcher = ref(currentDayComponent);
const employeeEmail = inject<string>('employeeEmail'); const employeeEmail = inject<string>('employeeEmail');
const CURRENT_DATE_STRING = new Date().toISOString().slice(0, 10);
// ========== computed ======================================== // ========== computed ========================================
const timesheetRows = computed(() => { const timesheetRows = computed(() => {
@ -40,6 +42,11 @@
timesheet => timesheet.days.every(day => day.shifts.length < 1) timesheet => timesheet.days.every(day => day.shifts.length < 1)
) && timesheetStore.has_timesheet_preset); ) && timesheetStore.has_timesheet_preset);
// Day type helpers
const isWeekend = (rowIndex: number) => rowIndex === 0 || rowIndex === 6; // Sun=0, Sat=6
const _isPast = (dateStr: string) => dateStr < CURRENT_DATE_STRING;
const isMonday = (rowIndex: number) => rowIndex === 1;
// ========== methods ======================================== // ========== methods ========================================
const onClickApplyWeeklyPreset = async (timesheet_id: number) => { const onClickApplyWeeklyPreset = async (timesheet_id: number) => {
@ -61,31 +68,37 @@
<div <div
:key="timesheetStore.is_loading ? 0 : 1" :key="timesheetStore.is_loading ? 0 : 1"
class="fit column no-wrap q-pb-lg" class="fit column no-wrap q-pb-lg"
:class="$q.platform.is.mobile ? '' : ''"
> >
<!-- Week column headers -->
<div class="row q-px-xs q-pb-xs" style="gap: 8px;">
<div <div
v-if="isShowingWeeklyPresets" v-for="(timesheet, ti) in timesheetStore.timesheets"
class="row flex-center q-py-xs" :key="ti"
> class="col row items-center"
<div
v-for="timesheet, timesheetIndex in timesheetStore.timesheets"
:key="timesheetIndex"
class="col row flex-center full-width"
> >
<span class="week-header">
<q-icon name="o_date_range" size="xs" class="q-mr-xs" />
{{ $t('timesheet_approvals.table.weekly_hours_' + (ti + 1)) || ('Semaine ' + (ti + 1)) }}
</span>
<q-space />
<q-btn <q-btn
:disable="!timesheet.days.every(day => day.shifts.length < 1)" v-if="isShowingWeeklyPresets && timesheet.days.every(day => day.shifts.length < 1)"
outline flat
dense dense
color="accent" no-caps
icon="schedule_send" size="sm"
icon="o_schedule_send"
:label="$t('timesheet.apply_preset_week')" :label="$t('timesheet.apply_preset_week')"
class="text-uppercase text-weight-bold q-px-xl q-py-xs rounded-50" color="accent"
:class="timesheet.days.every(day => day.shifts.length < 1) ? '' : 'invisible'" class="text-weight-medium"
@click="onClickApplyWeeklyPreset(timesheet.timesheet_id)" @click="onClickApplyWeeklyPreset(timesheet.timesheet_id)"
/> />
</div> </div>
</div> </div>
<!-- Day rows -->
<transition-group <transition-group
appear appear
enter-active-class="animated fadeInDown" enter-active-class="animated fadeInDown"
@ -93,8 +106,19 @@
<div <div
v-for="row, rowIndex of timesheetRows" v-for="row, rowIndex of timesheetRows"
:key="rowIndex" :key="rowIndex"
class="col-auto row items-stretch" class="col-auto"
:style="`animation-delay: ${rowIndex / 10}s;`" :style="`animation-delay: ${rowIndex / 10}s;`"
>
<!-- Monday separator -->
<div v-if="isMonday(rowIndex)" class="week-separator">
<div class="week-separator-line"></div>
</div>
<div
class="row items-stretch"
:class="[
isWeekend(rowIndex) ? 'day-row-weekend' : '',
]"
> >
<div <div
v-for="day, dayIndex in row" v-for="day, dayIndex in row"
@ -109,6 +133,48 @@
/> />
</div> </div>
</div> </div>
</div>
</transition-group> </transition-group>
</div> </div>
</template> </template>
<style scoped lang="scss">
.week-header {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--q-accent);
display: flex;
align-items: center;
padding: 4px 8px;
opacity: 0.7;
}
.week-separator {
padding: 0 8px;
margin: 4px 0 2px;
}
.week-separator-line {
height: 1px;
background: linear-gradient(90deg, var(--q-accent) 0%, transparent 100%);
opacity: 0.25;
}
.day-row-weekend :deep(.shift-day-row) {
border-left: 3px solid #c8ccd3;
}
.day-row-weekend :deep(.bg-dark) {
background-color: #eceef2 !important;
}
:global(body.body--dark) .day-row-weekend :deep(.shift-day-row) {
border-left-color: #3a3a4a;
}
:global(body.body--dark) .day-row-weekend :deep(.bg-dark) {
background-color: #22222e !important;
}
</style>

View File

@ -100,6 +100,14 @@
await onClickLeave(); await onClickLeave();
} }
const scrollToDay = (dateStr: string) => {
const card = document.querySelector(`[data-date="${dateStr}"]`);
if (!card) return;
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
card.classList.add('day-highlight-flash');
setTimeout(() => card.classList.remove('day-highlight-flash'), 1500);
};
onMounted(async () => { onMounted(async () => {
if (mode === 'normal') if (mode === 'normal')
await timesheetApi.getTimesheetsByDate(date.formatDate(new Date(), 'YYYY-MM-DD')); await timesheetApi.getTimesheetsByDate(date.formatDate(new Date(), 'YYYY-MM-DD'));
@ -108,8 +116,8 @@
<template> <template>
<div <div
class="column items-center full-height" class="column items-center"
:class="mode === 'normal' ? 'relative-position' : ' no-wrap'" :class="mode === 'normal' ? '' : 'no-wrap'"
> >
<LoadingOverlay v-model="timesheetStore.is_loading" /> <LoadingOverlay v-model="timesheetStore.is_loading" />
@ -127,28 +135,45 @@
<!-- weekly overview --> <!-- weekly overview -->
<div class="col-auto row q-px-lg full-width">
<!-- supervisor weekly overview -->
<div <div
v-if="!$q.platform.is.mobile" v-if="!$q.platform.is.mobile"
class="col-xs-6 col-md-4 col-xl-3 q-pa-md" class="col-auto row q-px-xl q-pt-sm full-width items-start"
style="gap: 16px;"
> >
<!-- Left: hours + expenses -->
<div class="col column" style="gap: 8px;">
<ShiftListWeeklyOverview <ShiftListWeeklyOverview
mode="total-hours" mode="total-hours"
:timesheet-mode="mode" :timesheet-mode="mode"
:weekly-hours="weeklyHours" :weekly-hours="weeklyHours"
:total-hours="totalHours" :total-hours="totalHours"
:total-expenses="totalExpenses" :total-expenses="totalExpenses"
@day-click="scrollToDay"
/> />
<!-- Expenses row under hours card aligned right -->
<div v-if="mode === 'normal'" class="row items-center justify-end" style="gap: 10px;">
<q-btn
flat
dense
no-caps
icon="o_receipt_long"
:label="$t('timesheet.expense.open_btn')"
color="accent"
class="text-weight-bold q-px-md"
style="border: 1px solid currentColor; border-radius: 8px;"
@click="expenseStore.open"
/>
<div class="expense-total-badge">
<q-icon name="o_payments" size="xs" class="q-mr-xs" />
{{ totalExpenses.toFixed(2) }}$
</div>
</div>
</div> </div>
<q-space v-if="!$q.platform.is.mobile" /> <q-space />
<!-- employee weekly overview --> <!-- Right: PTO -->
<div <div class="col">
v-if="!$q.platform.is.mobile"
class="col-xs-6 col-md-4 col-xl-3 q-pa-md"
>
<ShiftListWeeklyOverview <ShiftListWeeklyOverview
mode="off-hours" mode="off-hours"
:timesheet-mode="mode" :timesheet-mode="mode"
@ -156,13 +181,13 @@
</div> </div>
</div> </div>
<!-- top menu --> <!-- toolbar: nav + save -->
<div <div
v-if="mode === 'normal'" v-if="mode === 'normal'"
class="col-auto row items-center full-width" class="col-auto row items-center full-width q-px-xl q-pt-md q-pb-sm"
:class="$q.platform.is.mobile && ($q.screen.width < $q.screen.height) ? 'justify-between q-px-md' : 'q-pb-sm q-px-xl'" style="gap: 8px;"
> >
<!-- navigation btn --> <!-- nav group -->
<PayPeriodNavigator <PayPeriodNavigator
class="col-auto" class="col-auto"
@date-selected="timesheetApi.getTimesheetsByDate" @date-selected="timesheetApi.getTimesheetsByDate"
@ -170,47 +195,26 @@
@pressed-next-button="timesheetApi.getTimesheetsByCurrentPayPeriod" @pressed-next-button="timesheetApi.getTimesheetsByCurrentPayPeriod"
/> />
<!-- mobile expenses button --> <q-space />
<div
v-if="$q.screen.width < $q.screen.height && mode === 'normal'"
class="col q-pl-lg"
>
<q-btn
push
rounded
color="accent"
icon="receipt_long"
class="full-width"
@click="expenseStore.open"
/>
</div>
<q-space v-if="$q.screen.width > $q.screen.height" /> <!-- save only -->
<div class="row items-center" style="gap: 8px;">
<!-- desktop expenses button -->
<q-btn <q-btn
v-if="mode === 'normal' && $q.screen.width > $q.screen.height" v-if="!isTimesheetsApproved"
push flat
rounded dense
color="accent" no-caps
icon="receipt_long"
:label="$t('timesheet.expense.open_btn')"
@click="expenseStore.open"
/>
<!-- desktop save timesheet changes button -->
<q-btn
v-if="!isTimesheetsApproved && $q.screen.width > $q.screen.height"
push
rounded
:disable="timesheetStore.is_loading || hasShiftErrors || !timesheetStore.canSaveTimesheets" :disable="timesheetStore.is_loading || hasShiftErrors || !timesheetStore.canSaveTimesheets"
:color="timesheetStore.is_loading || hasShiftErrors || !timesheetStore.canSaveTimesheets ? 'grey-5' : 'accent'" icon="o_cloud_upload"
icon="upload" :label="$q.screen.width > 900 ? $t('shared.label.save') : ''"
:label="$t('shared.label.save')" :color="timesheetStore.is_loading || hasShiftErrors || !timesheetStore.canSaveTimesheets ? 'grey-5' : 'white'"
:class="$q.platform.is.mobile && ($q.screen.width < $q.screen.height) ? 'full-width' : 'q-ml-md'" :class="timesheetStore.is_loading || hasShiftErrors || !timesheetStore.canSaveTimesheets ? 'bg-grey-4' : 'bg-accent'"
class="text-weight-bold q-px-md"
style="border-radius: 8px;"
@click="onClickSaveTimesheets" @click="onClickSaveTimesheets"
/> />
</div> </div>
</div>
<!-- error message widget for potential backend-provided errors --> <!-- error message widget for potential backend-provided errors -->
<TimesheetErrorWidget class="col-auto" /> <TimesheetErrorWidget class="col-auto" />
@ -222,7 +226,6 @@
<ShiftListScrollable <ShiftListScrollable
v-if="mode === 'normal'" v-if="mode === 'normal'"
:mode="mode" :mode="mode"
:class="mode === 'normal' ? 'col' : 'col-auto'"
/> />
<!-- full shift list for timesheet approval details dialog --> <!-- full shift list for timesheet approval details dialog -->
@ -280,3 +283,28 @@
/> />
</div> </div>
</template> </template>
<style scoped>
:global(.day-highlight-flash) {
animation: dayFlash 1.5s ease;
}
@keyframes dayFlash {
0%, 100% { box-shadow: none; }
20%, 80% { box-shadow: 0 0 0 3px var(--q-accent), 0 0 20px rgba(14, 165, 80, 0.3); }
}
.expense-total-badge {
display: inline-flex;
align-items: center;
font-size: 0.9rem;
font-weight: 800;
color: #1e1e2a;
background: rgba(14, 165, 80, 0.1);
border: 1.5px solid rgba(14, 165, 80, 0.3);
border-radius: 8px;
padding: 4px 12px;
letter-spacing: 0.01em;
font-variant-numeric: tabular-nums;
}
</style>

View File

@ -58,25 +58,25 @@ export const getTimeStringFromMinutes = (minutes: number): string => {
} }
export const SHIFT_OPTIONS: ShiftOption[] = [ export const SHIFT_OPTIONS: ShiftOption[] = [
{ label: 'timesheet.shift.types.REGULAR', value: 'REGULAR', icon: 'wb_sunny', icon_color: 'accent' }, { label: 'timesheet.shift.types.REGULAR', value: 'REGULAR', icon: 'o_light_mode', icon_color: 'accent' },
{ label: 'timesheet.shift.types.EVENING', value: 'EVENING', icon: 'bedtime', icon_color: 'orange-5' }, { label: 'timesheet.shift.types.EVENING', value: 'EVENING', icon: 'o_dark_mode', icon_color: 'orange-5' },
{ label: 'timesheet.shift.types.EMERGENCY', value: 'EMERGENCY', icon: 'ring_volume', icon_color: 'red-5' }, { label: 'timesheet.shift.types.EMERGENCY', value: 'EMERGENCY', icon: 'o_warning_amber', icon_color: 'red-5' },
{ label: 'timesheet.shift.types.VACATION', value: 'VACATION', icon: 'beach_access', icon_color: 'deep-orange-5' }, { label: 'timesheet.shift.types.VACATION', value: 'VACATION', icon: 'o_flight_takeoff', icon_color: 'deep-orange-5' },
{ label: 'timesheet.shift.types.HOLIDAY', value: 'HOLIDAY', icon: 'event', icon_color: 'purple-5' }, { label: 'timesheet.shift.types.HOLIDAY', value: 'HOLIDAY', icon: 'o_celebration', icon_color: 'purple-5' },
{ label: 'timesheet.shift.types.SICK', value: 'SICK', icon: 'medication_liquid', icon_color: 'light-blue-6' }, { label: 'timesheet.shift.types.SICK', value: 'SICK', icon: 'o_medical_services', icon_color: 'light-blue-6' },
// { label: 'timesheet.shift.types.BANKING', value: 'BANKING', icon: 'savings', icon_color: 'pink-3' }, // { label: 'timesheet.shift.types.BANKING', value: 'BANKING', icon: 'o_savings', icon_color: 'pink-3' },
{ label: 'timesheet.shift.types.WITHDRAW_BANKED', value: 'WITHDRAW_BANKED', icon: 'attach_money', icon_color: 'yellow-4' }, { label: 'timesheet.shift.types.WITHDRAW_BANKED', value: 'WITHDRAW_BANKED', icon: 'o_account_balance_wallet', icon_color: 'yellow-8' },
]; ];
export const getShiftOptions = (disablePTO: boolean, isNotUnique: boolean): ShiftOption[] => { export const getShiftOptions = (disablePTO: boolean, isNotUnique: boolean): ShiftOption[] => {
return [ return [
{ label: 'timesheet.shift.types.REGULAR', value: 'REGULAR', icon: 'wb_sunny', icon_color: 'accent' }, { label: 'timesheet.shift.types.REGULAR', value: 'REGULAR', icon: 'o_light_mode', icon_color: 'accent' },
{ label: 'timesheet.shift.types.EVENING', value: 'EVENING', icon: 'bedtime', icon_color: 'orange-5' }, { label: 'timesheet.shift.types.EVENING', value: 'EVENING', icon: 'o_dark_mode', icon_color: 'orange-5' },
{ label: 'timesheet.shift.types.EMERGENCY', value: 'EMERGENCY', icon: 'ring_volume', icon_color: 'red-5' }, { label: 'timesheet.shift.types.EMERGENCY', value: 'EMERGENCY', icon: 'o_warning_amber', icon_color: 'red-5' },
{ label: 'timesheet.shift.types.VACATION', value: 'VACATION', icon: 'beach_access', icon_color: 'deep-orange-5', disable: disablePTO }, { label: 'timesheet.shift.types.VACATION', value: 'VACATION', icon: 'o_flight_takeoff', icon_color: 'deep-orange-5', disable: disablePTO },
{ label: 'timesheet.shift.types.HOLIDAY', value: 'HOLIDAY', icon: 'event', icon_color: 'purple-5', disable: isNotUnique || disablePTO }, { label: 'timesheet.shift.types.HOLIDAY', value: 'HOLIDAY', icon: 'o_celebration', icon_color: 'purple-5', disable: isNotUnique || disablePTO },
{ label: 'timesheet.shift.types.SICK', value: 'SICK', icon: 'medication_liquid', icon_color: 'light-blue-6', disable: disablePTO }, { label: 'timesheet.shift.types.SICK', value: 'SICK', icon: 'o_medical_services', icon_color: 'light-blue-6', disable: disablePTO },
// { label: 'timesheet.shift.types.BANKING', value: 'BANKING', icon: 'savings', icon_color: 'pink-3' }, // { label: 'timesheet.shift.types.BANKING', value: 'BANKING', icon: 'o_savings', icon_color: 'pink-3' },
// { label: 'timesheet.shift.types.WITHDRAW_BANKED', value: 'WITHDRAW_BANKED', icon: 'attach_money', icon_color: 'yellow-4' }, // { label: 'timesheet.shift.types.WITHDRAW_BANKED', value: 'WITHDRAW_BANKED', icon: 'o_account_balance_wallet', icon_color: 'yellow-8' },
]; ];
} }

View File

@ -30,7 +30,7 @@
<div class="col-auto full-width q-py-sm"> <div class="col-auto full-width q-py-sm">
<ShortcutCard <ShortcutCard
icon-image-source="img:src/assets/links/logo_gmail.png" icon-image-source="o_mail"
name="Messagerie" name="Messagerie"
route="https://mail.google.com/mail/u/0/#inbox" route="https://mail.google.com/mail/u/0/#inbox"
/> />
@ -38,7 +38,7 @@
<div class="col-auto full-width q-py-sm"> <div class="col-auto full-width q-py-sm">
<ShortcutCard <ShortcutCard
icon-image-source="img:src/assets/links/facturation-transparent.png" icon-image-source="o_receipt_long"
name="Facturation" name="Facturation"
route="https://facturation.targo.ca/facturation/accueil.php?menu=ticket_open" route="https://facturation.targo.ca/facturation/accueil.php?menu=ticket_open"
/> />
@ -46,7 +46,7 @@
<div class="col-auto full-width q-py-sm"> <div class="col-auto full-width q-py-sm">
<ShortcutCard <ShortcutCard
icon-image-source="location_on" icon-image-source="o_location_on"
name="Map Targo" name="Map Targo"
route="https://map.targointernet.com/infrastructure/map" route="https://map.targointernet.com/infrastructure/map"
/> />
@ -54,7 +54,7 @@
<div class="col-auto full-width q-py-sm"> <div class="col-auto full-width q-py-sm">
<ShortcutCard <ShortcutCard
icon-image-source="img:src/assets/links/hydroQC_icon.png" icon-image-source="o_flash_on"
name="Info Pannes" name="Info Pannes"
route="https://infopannes.solutions.hydroquebec.com/info-pannes/pannes/pannes-en-cours" route="https://infopannes.solutions.hydroquebec.com/info-pannes/pannes/pannes-en-cours"
/> />
@ -62,7 +62,7 @@
<div class="col-auto full-width q-py-sm"> <div class="col-auto full-width q-py-sm">
<ShortcutCard <ShortcutCard
icon-image-source="language" icon-image-source="o_language"
name="Intranet" name="Intranet"
route="https://intranet.facturation.targo.ca/" route="https://intranet.facturation.targo.ca/"
/> />

View File

@ -11,19 +11,18 @@
</script> </script>
<template> <template>
<q-page class="column bg-secondary items-center"> <q-page class="column bg-secondary items-center" padding>
<div <div
class="col column fit" class="column full-width"
:style="$q.platform.is.mobile && ($q.screen.width < $q.screen.height) ? '' : 'width: 90vw'" :style="$q.platform.is.mobile && ($q.screen.width < $q.screen.height) ? '' : 'max-width: 90vw'"
> >
<PageHeaderTemplate <PageHeaderTemplate
:title="'timesheet.page_header'" :title="'timesheet.page_header'"
:start-date="timesheet_store.pay_period?.period_start ?? ''" :start-date="timesheet_store.pay_period?.period_start ?? ''"
:end-date="timesheet_store.pay_period?.period_end ?? ''" :end-date="timesheet_store.pay_period?.period_end ?? ''"
class="col-auto"
/> />
<TimesheetWrapper class="col" /> <TimesheetWrapper />
</div> </div>
</q-page> </q-page>
</template> </template>

View File

@ -14,10 +14,18 @@ export const useAuthStore = defineStore('auth', () => {
//TODO: manage customer login process //TODO: manage customer login process
}; };
const oidcLogin = () => { const oidcLogin = async () => {
window.addEventListener('message', (event) => { // DEV: try direct profile fetch first (works when backend has DEV_BYPASS_AUTH)
void handleAuthMessage(event); try {
}); const result = await getProfile();
if (result.status === 200 && user.value) {
void router.push('/');
return;
}
} catch (e) { console.warn('DEV bypass login failed, falling back to OIDC:', e); }
// Normal OIDC popup flow
window.addEventListener('message', (event) => void handleAuthMessage(event));
const oidc_popup = window.open(`${import.meta.env.VITE_TARGO_BACKEND_URL}auth/v1/login`, 'authPopup', 'width=600,height=800'); const oidc_popup = window.open(`${import.meta.env.VITE_TARGO_BACKEND_URL}auth/v1/login`, 'authPopup', 'width=600,height=800');