Merge branch 'main' of git.targo.ca:Targo/targo_frontend into dev/lion/chatbot

This commit is contained in:
Nicolas Drolet 2026-01-09 16:25:04 -05:00
commit e3c596a5f2
222 changed files with 10033 additions and 4805 deletions

20
Dockerfile Normal file
View File

@ -0,0 +1,20 @@
# Step 1 - Building the app
FROM node:22 AS build
WORKDIR /app
COPY package*.json ./
# Set environment variables
ARG BACKEND_URL
ENV VITE_TARGO_BACKEND_URL=$BACKEND_URL
# Install dependencies
RUN npm install -g @quasar/cli
COPY . .
RUN npm install
RUN npm run build
# Step 2 - Move Applicatin to Nginx
FROM nginx:alpine
COPY --from=build /app/dist/spa /usr/share/nginx/html
RUN mkdir /usr/share/nginx/html/src
COPY --from=build /app/src /usr/share/nginx/html/src
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

26
docker-compose.yaml Normal file
View File

@ -0,0 +1,26 @@
---
name: app-targo
services:
backend:
build:
context: https://git.targo.ca/Targo/targo_backend.git
args:
- CALLBACK_URL=http://${SERVER_IP}:${BACKEND_PUBLIC_PORT}/auth/callback
environment:
- REDIRECT_URL_DEV=http://${SERVER_IP}:${FRONTEND_PUBLIC_PORT}/#/login-success
env_file:
- stack.env
ports:
- ${BACKEND_PUBLIC_PORT}:3000
frontend:
build:
context: https://git.targo.ca/Targo/targo_frontend.git
args:
- BACKEND_URL=http://${SERVER_IP}:${BACKEND_PUBLIC_PORT}/
volumes:
- .:/app
env_file:
- stack.env
ports:
- ${FRONTEND_PUBLIC_PORT}:80

View File

@ -41,6 +41,11 @@ export default defineConfigWithVueTs(
'error', 'error',
{ prefer: 'type-imports' } { prefer: 'type-imports' }
], ],
"no-unused-vars": "off",
'@typescript-eslint/no-unused-vars': [
'warn',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' }
],
} }
}, },
// https://github.com/vuejs/eslint-config-typescript // https://github.com/vuejs/eslint-config-typescript
@ -63,15 +68,15 @@ export default defineConfigWithVueTs(
} }
}, },
files: ['**/*.ts', '**/*.vue'],
// add your custom rules here // add your custom rules here
rules: { rules: {
'prefer-promise-reject-errors': 'off', 'prefer-promise-reject-errors': 'off',
// warn about unused but underscored variables // warn about unused but underscored variables
'no-unused-vars': [
'warn', 'no-unused-vars': 'off',
{ argsIgnorePattern: '^_' }
],
// allow debugger during development only // allow debugger during development only
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off' 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off'

View File

@ -29,11 +29,12 @@ export default defineConfig((ctx) => {
// 'fontawesome-v6', // 'fontawesome-v6',
// 'eva-icons', // 'eva-icons',
// 'themify', // 'themify',
// 'line-awesome', 'line-awesome',
// 'roboto-font-latin-ext', // this or either 'roboto-font', NEVER both! 'material-icons',
'material-icons-outlined',
'roboto-font', // optional, you are not bound to it 'roboto-font',
'material-icons', // optional, you are not bound to it // 'roboto-font-latin-ext', // this or either 'roboto-font', NEVER both!
], ],
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#build // Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#build
@ -97,6 +98,7 @@ export default defineConfig((ctx) => {
devServer: { devServer: {
// https: true, // https: true,
open: true, // opens browser window automatically open: true, // opens browser window automatically
allowedHosts: true,
}, },
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#framework // https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#framework
@ -104,9 +106,8 @@ export default defineConfig((ctx) => {
config: { config: {
notify: { notify: {
color: 'primary', color: 'primary',
avatar: 'https://cdn.quasar.dev/img/boy-avatar.png',
}, },
dark: false, dark: 'auto',
}, },
// iconSet: 'material-icons', // Quasar icon set // iconSet: 'material-icons', // Quasar icon set

View File

@ -1,5 +1,7 @@
<script setup lang="ts"> <script
// setup
lang="ts"
>
</script> </script>
<template> <template>

BIN
src/assets/en-CA.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

BIN
src/assets/fr-FR.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 880 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 419 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 816 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 778 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 802 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 804 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 739 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 681 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
src/assets/info-pannes.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 938 KiB

After

Width:  |  Height:  |  Size: 578 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 MiB

After

Width:  |  Height:  |  Size: 7.4 MiB

View File

@ -14,7 +14,10 @@ declare module 'vue' {
// good idea to move this instance creation inside of the // good idea to move this instance creation inside of the
// "export default () => {}" function below (which runs individually // "export default () => {}" function below (which runs individually
// for each client) // for each client)
const api = axios.create({ baseURL: import.meta.env.VITE_TARGO_BACKEND_AUTH_URL }); const api = axios.create({
baseURL: import.meta.env.VITE_TARGO_BACKEND_URL,
withCredentials: true
});
export default defineBoot(({ app }) => { export default defineBoot(({ app }) => {
// for use inside Vue files (Options API) through this.$axios and this.$api // for use inside Vue files (Options API) through this.$axios and this.$api

View File

@ -1,11 +1,10 @@
// app global css in SCSS form // app global css in SCSS form
@each $size in (1, 2, 3, 4, 5, 10, 15, 20, 25, 50, 75, 100) { @each $size in (1, 2, 3, 4, 5, 10, 15, 20, 25, 50, 75, 100, 200) {
.rounded-#{$size} { .rounded-#{$size} {
border-radius: #{$size}px !important; border-radius: #{$size}px !important;
} }
} }
.text-fb-blue { .text-fb-blue {
color: #4267B2 !important; color: #4267B2 !important;
} }
@ -25,7 +24,7 @@
} }
body.body--dark { body.body--dark {
--q-secondary: #0f1114; --q-secondary: #151520;
color: $grey-2; color: $grey-2;
} }
@ -33,3 +32,45 @@ body.body--dark {
--q-dark: #FFF; --q-dark: #FFF;
color: $blue-grey-8; color: $blue-grey-8;
} }
.shift-highlight {
background: #0195462a;
}
.frosted-glass {
background-color: #0008 !important;
backdrop-filter: blur(5px);
}
.q-btn--push::before {
border-bottom: 4px solid rgba(0,0,0, 0.25);
}
.q-btn--push:active {
transform: translateY(3px);
}
.q-btn--push:active::before {
border-bottom-width: 1px;
}
/* Chrome, Safari, Edge, Opera */
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Firefox */
input[type=number] {
-moz-appearance: textfield;
}
.q-field--dark .q-field__control::before {
border-color: #fff3;
}
.q-field--dark .q-field__control:hover::before, .q-field--outlined .q-field__control:hover::before {
border-color: var(--q-accent);
border-width: 2px;
}

View File

@ -12,21 +12,29 @@
// to match your app's branding. // to match your app's branding.
// Tip: Use the "Theme Builder" on Quasar's documentation website. // Tip: Use the "Theme Builder" on Quasar's documentation website.
$primary : #019547; $primary : #30303A;
$secondary : #DAE0E7; $secondary : #DAE0E7;
$accent : #AAD5C4; $accent : #0c9a3b;
$accent2 : #0a7d32;
$dark-shadow-color : #019547; $dark-shadow-color : #000000;
$elevation-dark-umbra : rgba($dark-shadow-color, 0.4); $elevation-dark-umbra : rgba($dark-shadow-color, 1);
$elevation-dark-penumbra : rgba($dark-shadow-color, 0); $elevation-dark-penumbra : rgba($dark-shadow-color, 0.75);
$elevation-dark-ambient : rgba($dark-shadow-color, 0); $elevation-dark-ambient : rgba($dark-shadow-color, 0.53);
$dark-shadow-2 : 0 3px 5px -1px $elevation-dark-umbra, 0 5px 8px $elevation-dark-penumbra, 0 1px 14px $elevation-dark-ambient; $dark-shadow-2 : 2px 3px $elevation-dark-umbra, 2px 3px 6px $elevation-dark-penumbra, 2px 3px 14px $elevation-dark-ambient;
$layout-shadow-dark : 0 0 10px 5px rgba($dark-shadow-color, 0.5); $layout-shadow-dark : 0 0 5px 5px rgba($dark-shadow-color, 0.5);
$dark : #333; $input-text-color : #455A64;
$dark-page : #343434; $input-autofill-color : #AAD5C4;
$field-dense-label-top : 5px !default;
$field-dense-label-font-size : 16px !default;
$button-shadow : 0 0 0 transparent;
$dark : #40404C;
$dark-page : #343444;
$positive : #21ba45; $positive : #21ba45;
$negative : #e6364b; $negative : #e6364b;

View File

@ -1,11 +1,73 @@
export default { export default {
chatbot: { chatbot: {
chat_header: "AI Assistant", chat_header: "AI Assistant",
chat_initial_message: chat_initial_message: "Welcome to your technical assistant.\nPlease provide the Customer ID to get a diagnostic report",
"Welcome to your technical assistant.\nPlease provide the Customer ID to get a diagnostic report",
chat_placeholder: "Enter a Message", chat_placeholder: "Enter a Message",
chat_thinking: "Thinking...", chat_thinking: "Thinking...",
}, },
dashboard: {
carousel: {
welcome_title: "Welcome to the new Targo Application!",
welcome_message: "Development is complete and the application is live! Things have remained mostly the same, but with a new coat of paint, a more streamlined user experience, and most importantly, drastically improved security and optimization!",
help_title: "We have a help page!",
help_message: "We've modernized the app while trying to make as few functional changes as possible, but if there's ever any part of the site that leaves you scratching your head, feel free to check out the help page.",
},
useful_links: "useful links",
},
help: {
label: "Centre d'aide",
tutorial: {
dashboard: {
title: "Home Page",
news_feed: "News Feed",
chat_bot: "Technical chat-bot",
notifications: "Notifications",
},
personal_profile: {
title: "Personnal Profile",
personal_info: "Personal informations",
professional_info: "Professional informations",
},
timesheets: {
title: "Timesheet",
create_shift: "Add a new shift",
update_shift: "Update an existing shift",
delete_shift: "Removing a shift from the timesheet",
comment_shift: "Commenting a shift",
create_expense: "Add a new expense",
update_expense: "Update an existing expense",
delete_expense: "Removing an expense from the list",
},
employee_list: {
title: "Employee List",
terminated_employees: "Inactive employees",
},
employee_management: {
title: "Employee Management",
create_employee: "Creating a new employee",
update_employee: "Updating an existing employee's informations",
module_access: "App managing access tool",
schedule_preset: "Schedule preset management",
terminating_employee: "terminate an employee",
},
timesheets_approval: {
title: "Timesheets approval",
approval: "timesheet approvals",
inspect: "Inspect timesheets",
comment_expense: "Commenting an expense",
},
shared: {
search: "Advance search",
preferences: "Display mode",
calendar: "Navigation using the calendar",
display: "Display as cards or as a list"
},
},
},
employee_list: { employee_list: {
page_header: "Employee Directory", page_header: "Employee Directory",
table: { table: {
@ -16,13 +78,76 @@ export default {
role: "Role", role: "Role",
supervisor: "Supervisor", supervisor: "Supervisor",
company: "Company", company: "Company",
is_supervisor: "is a supervisor",
expected_daily_hours: "Expected Daily Hours",
active: "active",
inactive: "inactive",
}, },
errors: {
first_name_required: "Employee's first name is required",
last_name_required: "Employee's last name is required",
company_required: "Employee must be assigned to a company",
phone_number_required: "Employee's phone number required",
hire_date_required: "Employee's first work day must be entered",
daily_hours_required: "Provide employee's expected daily hours worked",
no_modules_warning: "All modules disabled. This will lock the user out.",
}
},
employee_management: {
add_employee: "Add employee",
modify_employee: "Modify employee",
access_label: "access",
details_label: "details",
schedule_label: "schedule",
can_be_entered_later: "OPTIONAL: can be entered later",
enter_delete_input: "type 'DELETE' to remove",
banked_hours: "available banked hours",
sick_hours: "available PTO hours",
vacation_hours: "available vacation hours",
schedule_presets: {
preset_list_placeholder: "Select a schedule",
preset_name_placeholder: "schedule preset name",
delete_warning: "",
delete_warning_employee_1: "This schedule is used by",
delete_warning_employee_2: "Deleting this preset will not affect previous timesheets, but they will no longer be able to apply this preset to their timesheets going forward.",
},
module_access: {
dashboard: "Dashboard",
employee_list: "employee list",
employee_management: "employee management",
personal_profile: "profile",
timesheets: "timesheets",
timesheets_approval: "timesheet approval",
user_access: "module access",
by_role: "by role",
by_module: "by module",
preset_admin: "administrator",
preset_employee: "employee",
uncheck_all: "remove all",
admin_description: "Check all modules",
employee_description: "Only check modules that are relevant to standard employees with no management access",
none_description: "Uncheck all modules",
usage_description: "You can use roles to enable preset modules, add or remove modules individually, or both",
},
filter: {
hide_terminated: "Hide inactive employees",
sort_by_tags: "sort by tags",
},
},
error: {
not_found_header: "page not found",
not_found_description: "You may have entered the wrong URL, or you may not have access to this page",
go_back: "go back",
}, },
login: { login: {
page_header: "account login", page_header: "account login",
email: "e-mail", email: "e-mail",
password: "password", password: "password",
connected: "Connected",
redirecting: "Redirecting...",
button: { button: {
connect: "connect", connect: "connect",
employee: "employee", employee: "employee",
@ -32,6 +157,10 @@ export default {
tooltip: { tooltip: {
coming_soon: "coming soon!", coming_soon: "coming soon!",
}, },
error: {
login_failed: "Failed to login",
popups_blocked: "Popups are blocked on this device",
},
}, },
nav_bar: { nav_bar: {
@ -61,17 +190,29 @@ export default {
company: "company", company: "company",
supervisor: "supervisor", supervisor: "supervisor",
hired_date: "hiring date", hired_date: "hiring date",
fired_date: "departure date",
bankroll_id: "payroll ID",
}, },
preferences: { preferences: {
tab_title: "preferences", tab_title: "preferences",
display_options: "display options", display_options: "Color mode",
language_options: "language options", language_options: "language options",
'fr-FR': "Français",
'en-CA': "English",
dark_mode: "dark", dark_mode: "dark",
light_mode: "light", light_mode: "light",
auto_mode: "auto",
update_successful: "Preferences saved",
update_failed: "Failed to save preferences",
},
schedule_presets: {
tab_title: "Schedule",
selected_schedule: "Selected Schedule Preset",
new_preset: "Build a new preset",
}, },
errors: { errors: {
must_enter_birthdate: "You must enter a valid birthdate", must_enter_birthdate: "You must enter a valid birthdate",
}, }
}, },
shared: { shared: {
@ -91,6 +232,8 @@ export default {
update: "update", update: "update",
modify: "modify", modify: "modify",
close: "close", close: "close",
download: "download",
open: "open",
}, },
misc: { misc: {
or: "or", or: "or",
@ -113,28 +256,33 @@ export default {
remote: "remote work", remote: "remote work",
}, },
weekday: { weekday: {
sunday: "dimanche", sun: "dimanche",
monday: "lundi", mon: "lundi",
tuesday: "mardi", tue: "mardi",
wednesday: "mercredi", wed: "mercredi",
thursday: "jeudi", thu: "jeudi",
friday: "vendredi", fri: "vendredi",
saturday: "samedi", sat: "samedi",
}, },
}, },
timesheet: { timesheet: {
page_header: "Timesheet", page_header: "Timesheet",
week: "week",
total_hours: "total hours: ",
total_expenses: "total expenses: ",
vacation_available: "vacation time available: ",
sick_available: "sick time available: ",
current_shifts: "shifts worked",
apply_preset: "auto-fill",
apply_preset_day: "Apply schedule to day",
apply_preset_week: "Apply schedule to week",
nav_button: { nav_button: {
calendar_date_picker: "Calendar", calendar_date_picker: "Calendar",
current_week: "This week", current_week: "This week",
next_week: "Next week", next_week: "Next period",
previous_week: "Previous week", previous_week: "Previous period",
}, },
save_button: "Save",
cancel_button: "Cancel",
remote_button: "Remote work",
delete_button: "Delete",
shift: { shift: {
actions: { actions: {
add: "Add Shift", add: "Add Shift",
@ -151,15 +299,8 @@ export default {
REGULAR: "Regular", REGULAR: "Regular",
SICK: "Sick Leave", SICK: "Sick Leave",
VACATION: "Vacation", VACATION: "Vacation",
REMOTE: "Remote work", REMOTE: "Remote",
}, OFFICE: "Office",
errors: {
not_found: "Shift not found",
overlap: "An overlaps occured between 2 or more shifts",
invalid: "Invalid shift`s entry",
unknown: "Unknown error",
comment_required: "A comment is required",
comment_too_long: "Your comment is too long",
}, },
fields: { fields: {
start: "Start (HH:mm)", start: "Start (HH:mm)",
@ -169,29 +310,16 @@ export default {
}, },
}, },
expense: { expense: {
add_expense: "Add Expense", add_expense: 'Add Expense',
amount: "Amount", amount: 'Amount',
date: "Date", date: 'Date',
empty_list: "No registered expenses", empty_list: 'No registered expenses',
employee_comment: "Comment", employee_comment: 'Comment',
supervisor_comment: "Supervisor note", supervisor_comment: 'Supervisor note',
errors: {
date_required_or_invalid: "the date is missing or invalid",
comment_required: "A comment required",
comment_too_long: "Your comment is too long",
amount_must_be_positive: "the amount cannot be under 0$",
mileave_must_be_positive: "the mileage cannot be under 0",
amount_xor_mileage:
"you cannot enter an amount and a mileage for the same expense",
mileage_required_for_type:
"you need to enter a value for mileage when you enter an expense of that type",
amount_required_for_type:
"you need to enter a value for amount when you enter an expense of that type",
},
hints: { hints: {
amount_or_mileage: "Either amount or mileage, not both", amount_or_mileage: "Either amount or mileage, not both",
comment_required: "A comment required", comment_required: "A comment required",
attach_file: "Attach File", attach_file: "Attach File"
}, },
mileage: "mileage", mileage: "mileage",
open_btn: "list of expenses", open_btn: "list of expenses",
@ -203,33 +331,142 @@ export default {
PER_DIEM: "Per Diem", PER_DIEM: "Per Diem",
EXPENSES: "expense", EXPENSES: "expense",
MILEAGE: "mileage", MILEAGE: "mileage",
PRIME_GARDE: "on-call allowance", ON_CALL: "on-call allowance",
}, },
}, },
errors: {
INVALID_SHIFT_TIME: "In and Out shift times are reversed",
SHIFT_OVERLAP: "An overlaps occured between 2 or more shifts",
SHIFT_OVERLAP_SHORT: "Overlap",
INVALID_SHIFT: "A shift contains missing or corrupted data",
SHIFT_TIME_REQUIRED: "Time missing",
SHIFT_TYPE_REQUIRED: "Shift type required",
SHIFT_NOT_FOUND: "Shift missing or deleted",
PAY_PERIOD_NOT_FOUND: "No pay period matching given dates",
EMPLOYEE_NOT_FOUND: "No employee matching current login details",
INVALID_TIMESHEET: "Timesheet data is missing or corrupted",
TIMESHEET_NOT_FOUND: "No timesheet found with provided data",
INVALID_EXPENSE: "An expense contains missing or corrupted data",
EXPENSE_NOT_FOUND: "No expense found with provided data",
UPDATE_ERROR: "Error while updating data",
},
}, },
timesheet_approvals: { timesheet_approvals: {
page_title: "Validation cartes de temps", page_title: "Validation cartes de temps",
table: {
full_name: "full name",
email: "email address",
expenses: "expenses",
mileage: "mileage",
verified: "approved",
unverified: "pending",
},
chart: { chart: {
hours_worked_title: "hours worked", hours_worked_title: "hours worked",
expenses_title: "expenses accrued", expenses_title: "expenses accrued",
}, },
print_report: { print_report: {
company: "company", title: "Download options",
description: "Choose what to include in the report",
company: "companies",
type: "type", type: "type",
shifts: "shifts", shifts: "shifts",
expenses: "expenses", expenses: "expenses",
options: "options",
},
table: {
full_name: "full name",
email: "email address",
is_approved: "approval",
expenses: "expenses",
mileage: "mileage",
verified: "approved",
unverified: "pending",
inactive: "inactive",
regular: "regular",
evening: "evening",
emergency: "emergency",
overtime: "overtime",
holiday: "holiday",
vacation: "vacation",
sick: "sick",
remote: "remote work",
weekly_hours_1: "1st week hours",
weekly_hours_2: "2nd week hours",
total_hours: "total hours",
filter_active: "show only active employees",
filter_team: "show my team only",
filter_columns: "Information displayed",
}, },
tooltip: { tooltip: {
button_detailed_view: "detailed view", button_detailed_view: "detailed view",
approve: "Approve",
unapprove: "remove approval",
},
},
descriptions: {
dashboard: {
menu: "To access the main menu, click the button located in the upper-left corner. This menu allows you to navigate through the entire application.",
news_feed: "General announcements and important updates are displayed here. This view is the same for all employees.",
notifications: "Notifications are accessible via the bell icon located in the upper-right corner. They allow you to quickly view information relevant to you, \
such as leave, vacation, or absence requests, overtime hours worked during the current week, \
and comments left by your supervisor.",
chat_bot: "To access the bot, simply click on the \"bot\" bubble. The conversational bot allows you to quickly find information about a client, \
an invoice, or a device.",
},
personal_profile: {
personal_info: "In the \"Personal\" tab, you can view your personal information, such as your name, phone numbers, \
address, and date of birth.",
professional_info: "In the \"Career\" tab, you can view your professional information, including your position, the name of the company you work for, \
your supervisor's name and email address, as well as your hire date.",
preferences: "In the \"Preferences\" tab, you can adjust certain settings based on your personal preferences, such as dark mode, \
language, and notifications.",
},
timesheets: {
create_shift: "To add a work shift, click the green \"Add Time\" tab and enter the following required information: \
the shift type, start time, end time, and whether the shift is on-site or remote. \
Then click \"Save\", located in the upper-right corner of the time card.",
update_shift: "To modify a work shift, click the information you want to update, adjust the value, then click \"Save\", \
located in the upper-right corner of the time card.",
delete_shift: "To delete a work shift, click the red trash icon associated with the entry.",
comment_shift: "To leave a comment on a work shift, click the conversation bubble icon located to the right of the shift entry, \
then click \"Save\" in the upper-right corner of the time card.",
create_expense: "To add an expense, access the expense list using the button located in the upper-right corner. \
You must then complete the following fields: the date (today's date is selected by default), the expense type, \
the amount or mileage, a comment justifying the expense, and attach a supporting document. \
Then click the \"Add\" button.",
update_expense: "To modify an expense, access the expense list, select the expense you wish to edit, make the necessary changes, \
then click \"Update\" to save.",
delete_expense: "To delete an expense, access the expense list and click the red trash icon.",
},
employee_list: {
terminated_employees: "This option allows you to show or hide employees who are no longer employed.",
},
employee_management: {
create_employee: "To create an employee, access the \"Add Employee\" menu located in the upper-left corner of the employee list. \
In the \"Details\" tab, first enter all required information, excluding the termination date. \
Then assign the appropriate access rights to the employee. Optionally, you may assign a preset schedule. \
Click \"Save\" to confirm the employee creation.",
update_employee: "To update an employee's information, access rights, or schedule, select the employee's profile and navigate to the appropriate tab. \
Make the necessary changes, then click \"Update\" to confirm.",
module_access: "To manage access to different parts of the application, select the desired employee's profile and navigate to the \"Access\" tab. \
Two selection options are available: by role or by module. If the employee is a supervisor, the \"Administrator\" role is recommended. \
For a standard employee, the \"Employee\" role is appropriate. Specific modules can also be selected in special cases.",
schedule_preset: "To assign, modify, or create a schedule for an employee, first select the employee, then navigate to the \"Schedule\" tab in the edit menu. \
You may select an existing schedule, create a new one by assigning a unique name, or copy an existing schedule, modify it, and rename it. \
Once satisfied, click \"Update\" to confirm your selection.",
terminating_employee: "To terminate an employee, select the employee's profile (or row). Once the edit menu is displayed, \
enter the termination date and click \"Update\".",
},
timesheets_approval: {
approval: "To approve a timesheet, click the bottom of the card where the lock icon is located. In list view, \
click the lock icon on the right side of the corresponding row.",
inspect: "To review an employee's work shifts or expenses, click the briefcase icon located in the upper-right corner. \
You will find statistics on hours worked as well as expenses incurred. Within this window, you may edit work shifts \
and expenses. Click \"Save\" to confirm the changes.",
comment_expense: "To leave a comment on an expense submitted by an employee, click the briefcase icon on the desired employee's card. \
Navigate to the expense list and click the supervisor comment bubble. \
Click \"Save\" to confirm your comment.",
},
shared: {
display: "This option allows you to choose the display mode that best suits your needs, either card view or detailed list view.",
search: "An advanced keyword search is available. Simply separate each keyword with a space, and the search bar will return results \
that include all entered keywords.",
calendar: "The calendar speeds up navigation. It allows you to select a specific date and display the pay period that includes the selected date.",
}, },
}, },
}; };

View File

@ -1,11 +1,73 @@
export default { export default {
chatbot: { chatbot: {
chat_header: "Agent IA", chat_header: "Agent IA",
chat_initial_message: chat_initial_message: "Bienvenue à votre assistant technique.\nVeuillez fournir le ID du Client pour avoir un diagnostique",
"Bienvenue à votre assistant technique.\nVeuillez fournir le ID du Client pour avoir un diagnostique",
chat_placeholder: "Entré un Message", chat_placeholder: "Entré un Message",
chat_thinking: "Réflexion en cours...", chat_thinking: "Réflexion en cours...",
}, },
dashboard: {
carousel: {
welcome_title: "Bienvenue dans la nouvelle application Targo!",
welcome_message: "La nouvelle application est officiellement en ligne ! Plus performante et plus sécuritaire, elle conserve lessentiel avec un design rafraîchie.",
help_title: "Nous avons une page d'aide!",
help_message: "Lapplication a été pensée pour être plus intuitive et moderne. En cas de doute, la page daide est à votre disposition.",
},
useful_links: "liens utiles",
},
help: {
label: "Centre d'aide",
tutorial: {
dashboard: {
title: "Page d'accueil",
menu: "Menu Principal",
news_feed: "Fil d'actualités",
chat_bot: "Robot conversationnel d'aide technique",
notifications: "Notifications",
},
personal_profile: {
title: "Profil",
personal_info: "Informations personnelles",
professional_info: "Informations professionnelles",
},
timesheets: {
title: "Carte de temps",
create_shift: "Inscrire un nouveau quart de travail",
update_shift: "Modifier un quart de travail existant",
delete_shift: "Supprimer un quart de travail de la carte de temps",
comment_shift: "Commenter un quart de travail",
create_expense: "Inscrire une dépense",
update_expense: "Modifier une dépense",
delete_expense: "Supprimer une dépense",
},
employee_list: {
title: "Répertoire des employés ",
terminated_employees: "Employés inactifs",
},
employee_management: {
title: "Gestion des employés ",
create_employee: "Création d'un nouvel employé",
update_employee: "Modifier les informations d'un employé",
module_access: "Outils de gestion des accès de l'application",
schedule_preset: "Gestion des horaires prédéterminés",
terminating_employee: "Rendre un employé inactif",
},
timesheets_approval: {
title: "Validation de Carte de temps",
approval: "Validation des cartes de temps",
inspect: "Inspect timesheets",
comment_expense: "Commenter une dépense",
},
shared: {
search: "Recherche avancée",
preferences: "Options de préférences d'affichage",
calendar: "Navigation avec l'aide du calendrier",
display: "Affichage par Cartes ou par Liste"
},
},
},
employee_list: { employee_list: {
page_header: "Répertoire du personnel", page_header: "Répertoire du personnel",
table: { table: {
@ -16,13 +78,76 @@ export default {
role: "rôle", role: "rôle",
supervisor: "superviseur", supervisor: "superviseur",
company: "Compagnie", company: "Compagnie",
is_supervisor: "est un superviseur",
expected_daily_hours: "Heures quotidiennes attendues",
active: "actif",
inactive: "inactif",
}, },
errors: {
first_name_required: "Vous devez spécifier le prénom",
last_name_required: "Vous devez spécifier le nom de famille",
company_required: "Vous devez assignerl'employé à une compagnie",
phone_number_required: "Vous devez entrer un numéro de téléphone",
hire_date_required: "Vous devez entrer une date d'embauche",
daily_hours_required: "Spécifiez le nombre d'heures quotidiennes travaillé",
no_modules_warning: "Tout les modules sont désactivés. L'utilisateur sera verrouillé hors de l'application.",
}
},
employee_management: {
add_employee: "Ajouter employé",
modify_employee: "Modifier employé",
access_label: "accès",
details_label: "détails",
schedule_label: "horaire",
can_be_entered_later: "FACULTATIF: peut être entré plus tard",
enter_delete_input: "tappez 'SUPPRIMER' pour confirmer",
banked_hours: "heures en banque disponibles",
sick_hours: "heures d'absence payées disponibles",
vacation_hours: "heures de vacances disponibles",
schedule_presets: {
preset_list_placeholder: "Sélectionner un horaire",
preset_name_placeholder: "nom de l'horaire",
delete_warning: "Êtes-vous certain de vouloir supprimer cet horaire?",
delete_warning_employee_1: "Cet horaire est présentement utilisé par",
delete_warning_employee_2: "La suppression n'affectera pas leurs feuilles de temps antérieures, mais ils ne pourront plus appliquer cet horaire à leurs feuilles de temps à partir de maintenant.",
},
module_access: {
dashboard: "Accueil",
employee_list: "Répertoire du personnel",
employee_management: "Gestion employés",
personal_profile: "profil personnel",
timesheets: "carte de temps",
timesheets_approval: "validation cartes de temps",
user_access: "module access",
by_role: "par rôle",
by_module: "par module",
preset_admin: "administrateur",
preset_employee: "employé",
uncheck_all: "Tout enlever",
admin_description: "Selectionner tous les modules",
employee_description: "Selectionner seulement les modules qui sont pertinents aux employés sans accès spéciaux",
none_description: "Enlever tous les accès",
usage_description: "Vous pouvez utiliser les rôles pour sélectionner des modules prédéfinis, enlever ou ajouter des modules individuellement, ou les deux",
},
filter: {
hide_terminated: "Cacher les employés inactifs",
sort_by_tags: "filtrer par identifiants",
},
},
error: {
not_found_header: "page introuvable",
not_found_description: "Vous avez possiblement entré une mauvaise addresse URL, ou vous n'avez pas accès à cette section du site",
go_back: "retour en arrière",
}, },
login: { login: {
page_header: "connexion au compte", page_header: "connexion au compte",
email: "courriel", email: "courriel",
password: "mot de passe", password: "mot de passe",
connected: "Connecté",
redirecting: "redirection en cours...",
button: { button: {
connect: "connecter", connect: "connecter",
employee: "employé", employee: "employé",
@ -32,6 +157,10 @@ export default {
tooltip: { tooltip: {
coming_soon: "à venir!", coming_soon: "à venir!",
}, },
error: {
login_failed: "Échec à la connexion",
popups_blocked: "Les fenêtres contextuelles sont bloqués sur cet appareil",
},
}, },
nav_bar: { nav_bar: {
@ -61,29 +190,41 @@ export default {
company: "compagnie", company: "compagnie",
supervisor: "nom du superviseur", supervisor: "nom du superviseur",
hired_date: "date d'embauche", hired_date: "date d'embauche",
fired_date: "date de départ",
bankroll_id: "identifiant de paie",
}, },
preferences: { preferences: {
tab_title: "préférences", tab_title: "préférences",
display_options: "Options d'affichage", display_options: "Mode d'affichage",
language_options: "Options de langue", language_options: "Options de langue",
'fr-FR': "Français",
'en-CA': "English",
dark_mode: "sombre", dark_mode: "sombre",
light_mode: "clair", light_mode: "clair",
auto_mode: "automatique",
update_successful: "Préférences enregistrées",
update_failed: "Échec de sauvegarde",
},
schedule_presets: {
tab_title: "horaire",
selected_schedule: "Horaire Sélectionné",
new_preset: "Construire un nouvel horaire",
}, },
errors: { errors: {
must_enter_birthdate: "Vous devez entrer une date de naissance valide", must_enter_birthdate: "Vous devez entrer une date de naissance valide",
}, }
}, },
shared: { shared: {
error: { error: {
no_data_found: "aucune donnée à afficher", no_data_found: 'aucune donnée à afficher',
no_search_results: "aucun résultat ne correspond à la recherche", no_search_results: 'aucun résultat ne correspond à la recherche',
}, },
label: { label: {
search: "recherche", search: 'recherche',
filter: "filtres", filter: "filtres",
loading: "chargement en cours...", loading: 'chargement en cours...',
language: "langue", language: 'langue',
add: "ajouter", add: "ajouter",
save: "sauvegarder", save: "sauvegarder",
remove: "supprimer", remove: "supprimer",
@ -91,6 +232,8 @@ export default {
update: "mettre à jour", update: "mettre à jour",
modify: "modifier", modify: "modifier",
close: "fermer", close: "fermer",
download: "télécharger",
open: "ouvrir",
}, },
misc: { misc: {
or: "ou", or: "ou",
@ -113,28 +256,33 @@ export default {
remote: "télétravail", remote: "télétravail",
}, },
weekday: { weekday: {
sunday: "dimanche", sun: "dimanche",
monday: "lundi", mon: "lundi",
tuesday: "mardi", tue: "mardi",
wednesday: "mercredi", wed: "mercredi",
thursday: "jeudi", thu: "jeudi",
friday: "vendredi", fri: "vendredi",
saturday: "samedi", sat: "samedi",
}, },
}, },
timesheet: { timesheet: {
page_header: "Carte de temps", page_header: "Carte de temps",
week: "semaine",
total_hours: "heures totales: ",
total_expenses: "dépenses totales: ",
vacation_available: "vacances disponibles: ",
sick_available: "congés maladie disponible: ",
current_shifts: "quarts entrées",
apply_preset: "auto-remplir",
apply_preset_day: "Appliquer horaire pour la journée",
apply_preset_week: "Appliquer horaire pour la semaine",
nav_button: { nav_button: {
calendar_date_picker: "Calendrier", calendar_date_picker: "Calendrier",
current_week: "Semaine actuelle", current_week: "Semaine actuelle",
next_week: "Prochaine semaine", next_week: "Prochaine période",
previous_week: "Semaine précédente", previous_week: "Période précédente",
}, },
save_button: "Enregistrer",
cancel_button: "Annuler",
remote_button: "Télétravail",
delete_button: "Supprimer",
shift: { shift: {
actions: { actions: {
add: "Ajouter un Quart", add: "Ajouter un Quart",
@ -152,14 +300,7 @@ export default {
SICK: "Maladie", SICK: "Maladie",
VACATION: "Vacance", VACATION: "Vacance",
REMOTE: "Télétravail", REMOTE: "Télétravail",
}, OFFICE: "Bureau",
errors: {
not_found: "Aucun quart trouvé",
overlap: "Il y a un chevauchement entre deux ou plusieurs quarts",
invalid: "Entrée du quart invalide",
unknown: "Erreur inconnue",
comment_required: "un commentaire est requis",
comment_too_long: "votre commentaire est trop long",
}, },
fields: { fields: {
start: "Début (HH:mm)", start: "Début (HH:mm)",
@ -169,29 +310,16 @@ export default {
}, },
}, },
expense: { expense: {
add_expense: "Ajouter une dépense", add_expense: 'Ajouter une dépense',
amount: "Montant", amount: 'Montant',
date: "Date", date: 'Date',
empty_list: "Aucun dépense enregistrée", empty_list: 'Aucun dépense enregistrée',
employee_comment: "Commentaire", employee_comment: 'Commentaire',
supervisor_comment: "Note du Superviseur", supervisor_comment: 'Note du Superviseur',
errors: {
date_required_or_invalid: "La date est manquante ou invalide",
comment_required: "un commentaire est requis",
comment_too_long: "votre commentaire est trop long",
amount_must_be_positive: "le montant doit être suppérieur à 0$",
mileave_must_be_positive: "le kilométrage doit être suppérieur à 0",
amount_xor_mileage:
"Vous ne pouvez pas saisir un montant et un kilométrage pour une même dépense",
mileage_required_for_type:
"Vous devez entrer une valeur en kilométrage pour ce type de dépense",
amount_required_for_type:
"Vous devez entrer une valeur en montant $ pour ce type de dépense",
},
hints: { hints: {
amount_or_mileage: "Soit dépense ou kilométrage, pas les deux", amount_or_mileage: "Soit dépense ou kilométrage, pas les deux",
comment_required: "un commentaire est requis", comment_required: "un commentaire est requis",
attach_file: "Pièce jointe", attach_file: "Pièce jointe"
}, },
mileage: "Kilométrage", mileage: "Kilométrage",
open_btn: "Liste des Dépenses", open_btn: "Liste des Dépenses",
@ -203,33 +331,142 @@ export default {
PER_DIEM: "Per diem", PER_DIEM: "Per diem",
EXPENSES: "dépense", EXPENSES: "dépense",
MILEAGE: "kilométrage", MILEAGE: "kilométrage",
PRIME_GARDE: "Prime de garde", ON_CALL: "Prime de garde",
}, },
}, },
errors: {
INVALID_SHIFT_TIME: "Les heures d'entrée et de sortie sont inversées",
SHIFT_OVERLAP: "Il y a un chevauchement entre deux ou plusieurs quarts",
SHIFT_OVERLAP_SHORT: "Chevauchement",
INVALID_SHIFT: "Un quart de travail contient des données manquantes ou corrompues",
SHIFT_TIME_REQUIRED: "Heures manquantes",
SHIFT_TYPE_REQUIRED: "Type requis",
SHIFT_NOT_FOUND: "Quart de travail manquant ou supprimé",
PAY_PERIOD_NOT_FOUND: "Aucune période de paie ne correspond aux dates fournies",
EMPLOYEE_NOT_FOUND: "Aucun employé ne correspond aux détails de votre connexion",
INVALID_TIMESHEET: "Une feuille de temps contient des données manquantes ou corrompues",
TIMESHEET_NOT_FOUND: "Aucune feuille de temps ne correspond au détails fournis",
INVALID_EXPENSE: "Une dépense contient des données manquantes ou corrompues",
EXPENSE_NOT_FOUND: "Aucune dépense ne correspond aux détails fournis",
UPDATE_ERROR: "Une erreur est survenu lors de la mise à jour",
},
}, },
timesheet_approvals: { timesheet_approvals: {
page_title: "Validation cartes de temps", page_title: "Validation cartes de temps",
chart: {
hours_worked_title: "heures travaillées",
expenses_title: "dépenses encourues"
},
print_report: {
title: "options de téléchargement",
description: "Choisissez ce qui sera inclu dans le rapport",
company: "compagnies",
type: "types de données",
shifts: "quarts de travail",
expenses: "dépenses",
options: "options",
},
table: { table: {
full_name: "nom complet", full_name: "nom complet",
email: "courriel", email: "courriel",
is_approved: "approuvé",
expenses: "dépenses", expenses: "dépenses",
mileage: "kilométrage", mileage: "kilométrage",
verified: "approuvé", verified: "approuvé",
unverified: "à vérifier", unverified: "à vérifier",
}, inactive: "inactif",
chart: { regular: "régulier",
hours_worked_title: "heures travaillées", evening: "soir",
expenses_title: "dépenses encourues", emergency: "urgence",
}, overtime: "supplémentaire",
print_report: { holiday: "férié",
company: "compagnie", vacation: "vacances",
type: "types de données", sick: "maladie",
shifts: "quarts de travail", remote: "télétravail",
expenses: "dépenses", weekly_hours_1: "heures semaine 1",
weekly_hours_2: "heures semaine 2",
total_hours: "heures totales",
filter_active: "montrer les employés inactifs",
filter_team: "montrer mon équipe seulement",
filter_columns: "informations affichés",
}, },
tooltip: { tooltip: {
button_detailed_view: "vue détaillée", button_detailed_view: "vue détaillée",
approve: "mettre status approuvé",
unapprove: "enlever status approuvé",
},
},
descriptions: {
dashboard: {
menu: "Pour accéder au menu principal, cliquez sur le bouton situé dans le coin supérieur gauche. Ce menu vous permet de naviguer à travers l'ensemble de l'application.",
news_feed: "Des annonces générales et des points importants y sont présentés. Cet affichage est identique pour l'ensemble des employés.",
notifications: "Les notifications sont accessibles via la clochette située dans le coin supérieur droit. Elles permettent de consulter rapidement les informations qui vous concernent, \
par exemple : les demandes de congé, de vacances ou d'absence, les heures supplémentaires effectuées durant la semaine courante, \
ainsi que les commentaires laissés par votre superviseur.",
chat_bot: "Pour accéder au robot, il suffit de cliquer sur la bulle « robot ». Le robot conversationnel vous permet de trouver rapidement de l'information sur un client, \
une facture ou un appareil.",
},
personal_profile: {
personal_info: "Dans l'onglet « Personnel », vous pouvez consulter vos informations personnelles, telles que votre nom, vos numéros de téléphone, \
votre adresse et votre date de naissance.",
professional_info: "Dans l'onglet « Carrière », vous pouvez consulter vos informations professionnelles, comme votre poste, le nom de l'entreprise pour laquelle vous travaillez, \
le nom et l'adresse courriel de votre superviseur, ainsi que votre date d'embauche.",
preferences: "Dans l'onglet « Préférences », vous pouvez ajuster certains paramètres selon vos préférences personnelles, tels que le mode sombre, \
la langue et les notifications.",
},
timesheets: {
create_shift: "Pour ajouter un quart de travail, cliquez sur l'onglet vert « Ajouter du temps » et saisissez les informations obligatoires suivantes : \
le type de quart, l'heure de début, l'heure de fin, ainsi que le mode de travail (présentiel ou télétravail). \
Cliquez ensuite sur « Sauvegarder », situé dans le coin supérieur droit de la carte de temps.",
update_shift: "Pour modifier un quart de travail, cliquez sur l'information à modifier, ajustez la valeur souhaitée, puis cliquez sur « Sauvegarder », \
situé dans le coin supérieur droit de la carte de temps.",
delete_shift: "Pour supprimer un quart de travail, cliquez sur l'icône de poubelle rouge associée à l'entrée.",
comment_shift: "Pour laisser un commentaire sur un quart de travail, cliquez sur l'icône de bulle conversationnelle située à droite de l'entrée du quart de travail, \
puis cliquez sur « Sauvegarder », dans le coin supérieur droit de la carte de temps.",
create_expense: "Pour ajouter une dépense, accédez à la liste des dépenses à l'aide du bouton situé dans le coin supérieur droit. \
Vous devez ensuite remplir les champs suivants : la date (la date du jour est sélectionnée par défaut), le type de dépense, \
le montant ou le kilométrage effectué, un commentaire justifiant la dépense, et joindre une pièce justificative. \
Cliquez ensuite sur le bouton « Ajouter ».",
update_expense: "Pour modifier une dépense, accédez à la liste des dépenses, sélectionnez la dépense à modifier, apportez les changements nécessaires, \
puis cliquez sur « Modifier » pour sauvegarder.",
delete_expense: "Pour supprimer une dépense, accédez à la liste des dépenses et cliquez sur l'icône de poubelle rouge.",
},
employee_list: {
terminated_employees: "Cette option vous permet d'afficher ou de masquer les employés qui ne sont plus à l'emploi.",
},
employee_management: {
create_employee: "Pour créer un employé, accédez au menu « Ajouter un employé », situé dans le coin supérieur gauche de la liste des employés. \
Dans l'onglet « Détails », vous devez d'abord saisir l'ensemble des informations requises, à l'exception de la date de départ. \
Ensuite, attribuez les accès nécessaires à l'employé. Optionnellement, vous pouvez lui assigner un horaire préfabriqué. \
Cliquez sur « Sauvegarder » pour confirmer la création de l'employé.",
update_employee: "Pour mettre à jour les informations, les accès ou l'horaire d'un employé, sélectionnez son portrait et naviguez vers l'onglet approprié. \
Apportez les modifications nécessaires, puis cliquez sur « Mettre à jour » pour confirmer.",
module_access: "Pour gérer les accès aux différentes parties de l'application, sélectionnez le portrait de l'employé désiré, puis accédez à l'onglet « Accès ». \
Deux options sont disponibles : par rôle ou par module. Si l'employé est superviseur, le rôle « Administrateur » est suggéré. \
Pour un employé standard, le rôle « Employé » est recommandé. Il est également possible de sélectionner des modules spécifiques dans certains cas.",
schedule_preset: "Pour attribuer, modifier ou créer un horaire pour un employé, sélectionnez d'abord l'employé, puis accédez à l'onglet « Horaire » du menu de modification. \
Vous pouvez choisir un horaire existant, créer un nouvel horaire en lui donnant un nom unique, ou copier un horaire existant, le modifier et le renommer. \
Une fois satisfait, cliquez sur « Mettre à jour » pour confirmer votre choix.",
terminating_employee: "Pour mettre fin à l'emploi d'un employé, sélectionnez son portrait (ou sa ligne). Une fois le menu de modification affiché, \
entrez la date de départ et cliquez sur « Mettre à jour ».",
},
timesheets_approval: {
approval: "Pour approuver une feuille de temps, cliquez sur le bas de la carte se trouve l'icône de cadenas. En mode liste, \
cliquez sur le cadenas situé à droite de la ligne correspondante.",
inspect: "Pour consulter les informations relatives aux quarts de travail ou aux dépenses d'un employé, cliquez sur l'icône de valise située dans le coin supérieur droit. \
Vous y trouverez des statistiques sur les heures travaillées ainsi que les dépenses effectuées. Dans cette fenêtre, il est possible de modifier des quarts de travail \
et des dépenses. Cliquez sur « Sauvegarder » pour confirmer les modifications.",
comment_expense: "Pour laisser un commentaire sur une dépense soumise par un employé, cliquez sur l'icône de valise associée à la carte de l'employé désiré. \
Accédez à la liste des dépenses et cliquez sur la bulle de commentaire du superviseur. \
Cliquez sur « Sauvegarder » pour confirmer votre commentaire.",
},
shared: {
display: "Cette option permet de choisir le mode d'affichage qui vous convient le mieux, soit par carte ou sous forme de liste détaillée.",
search: "Il est possible d'effectuer une recherche avancée par mots-clés. Il suffit de séparer chaque mot par un espace, et la barre de recherche affichera \
les résultats contenant l'ensemble des mots-clés saisis.",
calendar: "Le calendrier facilite la navigation. Il vous permet de sélectionner une date précise et d'afficher la période de paie qui inclut cette date.",
}, },
}, },
}; };

View File

@ -1,10 +1,20 @@
<script
setup
lang="ts"
>
const CREATE_YEAR = 2025;
const today = new Date();
</script>
<template> <template>
<q-footer <q-footer
elevated elevated
class="bg-primary text-white" class="bg-primary text-white"
> >
<q-toolbar> <div class="q-px-md q-py-xs full-width text-right">
<q-toolbar-title>© 2025 Targo Communications inc.</q-toolbar-title> <span class="text-weight-light text-caption text-uppercase">
</q-toolbar> © {{ CREATE_YEAR }} - {{ today.getFullYear() }} Targo Communications inc.
</span>
</div>
</q-footer> </q-footer>
</template> </template>

View File

@ -1,26 +1,29 @@
<script setup lang="ts"> <script
import { useAuthStore } from 'src/stores/auth-store'; setup
lang="ts"
>
import { ref } from 'vue'; import { ref } from 'vue';
const authStore = useAuthStore();
const currentUser = authStore.user;
// Will need to implement this eventually, just testing the look for now // Will need to implement this eventually, just testing the look for now
const notifAmount = ref(7); const notification_count = ref(7);
</script> </script>
<template> <template>
<q-item clickable v-ripple dark class="q-pa-none"> <q-btn
<q-item-section :side="$q.screen.gt.sm"> flat
<q-avatar rounded > transparent
<q-img src="src/assets/targo-default-avatar.png" /> dense
<q-badge floating color="negative" v-if="notifAmount > 0" >{{ notifAmount }}</q-badge> :icon="notification_count > 0 ? 'notifications_active' : 'notifications_off'"
</q-avatar> size="lg"
</q-item-section> color="white"
>
<q-item-section v-if="$q.screen.gt.sm"> <q-badge
<q-item-label>{{ currentUser.firstName }} {{ currentUser.lastName }}</q-item-label> v-if="notification_count > 0"
<q-item-label caption>{{ notifAmount }} new messages</q-item-label> floating
</q-item-section> color="negative"
</q-item> class="text-weight-bolder q-mt-xs"
>
{{ notification_count }}
</q-badge>
</q-btn>
</template> </template>

View File

@ -1,22 +1,25 @@
<script lang="ts" setup> <script
import { useUiStore } from 'src/stores/ui-store'; lang="ts"
import HeaderBarNotification from './main-layout-header-bar-notification.vue'; setup
import Chatbutton from "src/modules/chatbot/components/chat-button.vue"; >
import { useUiStore } from 'src/stores/ui-store';
// import HeaderBarNotification from './main-layout-header-bar-notification.vue';
const uiStore = useUiStore(); const uiStore = useUiStore();
</script> </script>
<template> <template>
<q-header elevated> <q-header elevated>
<q-toolbar> <q-toolbar class="q-px-sm">
<q-toolbar-title> <q-toolbar-title>
<q-btn <q-btn
flat flat
dense dense
color="white" color="white"
icon="menu"
@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="src/assets/logo-targo-white.svg"
fit="contain" fit="contain"
@ -26,12 +29,7 @@ const uiStore = useUiStore();
</q-btn> </q-btn>
</q-toolbar-title> </q-toolbar-title>
<q-item class="q-pa-none"> <q-item class="q-pa-none">
<!-- <HeaderBarNotification /> -->
<Chatbutton>
</Chatbutton>
<HeaderBarNotification />
</q-item> </q-item>
</q-toolbar> </q-toolbar>
</q-header> </q-header>

View File

@ -1,175 +1,113 @@
<script setup lang="ts"> <script
import { useRouter } from 'vue-router'; setup
import { useAuthStore } from 'src/stores/auth-store'; lang="ts"
import { useUiStore } from 'src/stores/ui-store'; >
import { ref } from 'vue'; import { onMounted, ref } from 'vue';
import { RouteNames } from 'src/router/router-constants'; import { useQuasar } from 'quasar';
import { useRouter } from 'vue-router';
import { useAuthStore } from 'src/stores/auth-store';
import { useUiStore } from 'src/stores/ui-store';
import { RouteNames } from 'src/router/router-constants';
import { ModuleNames, type UserModuleAccess } from 'src/modules/shared/models/user.models';
const authStore = useAuthStore(); const DRAWER_BUTTONS: { i18n_key: string, icon: string, route: RouteNames, required_module?: UserModuleAccess }[] = [
const uiStore = useUiStore(); { i18n_key: 'nav_bar.home', icon: "home", route: RouteNames.DASHBOARD, required_module: ModuleNames.DASHBOARD },
const router = useRouter(); { i18n_key: 'nav_bar.timesheet_approvals', icon: "event_available", route: RouteNames.TIMESHEET_APPROVALS, required_module: ModuleNames.TIMESHEETS_APPROVAL },
const miniState = ref(true); { i18n_key: 'nav_bar.employee_list', icon: "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.profile', icon: "account_box", route: RouteNames.PROFILE, required_module: ModuleNames.PERSONAL_PROFILE },
{ i18n_key: 'nav_bar.help', icon: "contact_support", route: RouteNames.HELP },
]
const q = useQuasar();
const auth_store = useAuthStore();
const ui_store = useUiStore();
const router = useRouter();
const is_mini = ref(true);
const goToPageName = (pageName: string) => { const onClickDrawerPage = (page_name: RouteNames) => {
router.push({ name: pageName }).catch(err => { is_mini.value = true;
console.error('Error with Vue Router: ', err);
router.push({ name: page_name }).catch(error => {
console.error('failed to reach page: ', error);
}); });
}; };
const handleLogout = () => { const handleLogout = () => {
authStore.logout(); auth_store.logout();
router.push({ name: 'login' }).catch(err => { router.push({ name: 'login' }).catch(err => {
console.log('could not log you out: ', err); console.error('could not log you out: ', err);
})
};
onMounted(() => {
if (q.platform.is.mobile) {
ui_store.is_left_drawer_open = false;
}
}) })
}
</script> </script>
<template> <template>
<q-drawer <q-drawer
v-model="uiStore.isRightDrawerOpen" v-model="ui_store.is_left_drawer_open"
overlay :persistent="!$q.platform.is.mobile"
mini-to-overlay
elevated elevated
side="left" side="left"
:mini="miniState" :mini="is_mini"
@mouseenter="miniState = false" @mouseenter="is_mini = false"
@mouseleave="miniState = true" @mouseleave="is_mini = true"
class="bg-dark" class="bg-dark z-max"
> >
<q-scroll-area class="fit"> <q-scroll-area class="column fit">
<q-list> <div
<!-- Home --> v-for="button, index in DRAWER_BUTTONS"
<q-item :key="index"
v-ripple v-show="button.required_module ?? true"
clickable @click="onClickDrawerPage(button.route)"
side >
@click="goToPageName(RouteNames.DASHBOARD)" <div
v-if="button.required_module ? auth_store.user?.user_module_access.includes(button.required_module) : true"
class="row items-center full-width q-py-sm cursor-pointer"
:class="$router.currentRoute.value.name === button.route ? ($q.dark.isActive ? 'bg-green-10' : 'bg-green-2') : ''"
> >
<q-item-section avatar>
<q-icon <q-icon
name="home" :name="button.icon"
color="primary" color="accent"
size="lg"
class="col-auto q-pl-sm"
/> />
</q-item-section>
<q-item-section>
<q-item-label class="text-uppercase text-weight-bold">{{ $t('nav_bar.home') }}</q-item-label>
</q-item-section>
</q-item>
<!-- Timesheet Validation -- Supervisor and Accounting only --> <div
<q-item class="col text-uppercase text-weight-bold text-h6 q-pl-sm"
v-ripple :class="$q.platform.is.mobile ? '' : 'q-mini-drawer-hide'"
clickable
side
@click="goToPageName(RouteNames.TIMESHEET_APPROVALS)"
v-if="['supervisor', 'accounting'].includes(authStore.user.role)"
> >
<q-item-section avatar> {{ $t(button.i18n_key) }}
<q-icon </div>
name="event_available" </div>
color="primary" </div>
/>
</q-item-section>
<q-item-section>
<q-item-label class="text-uppercase text-weight-bold">{{ $t('nav_bar.timesheet_approvals')
}}</q-item-label>
</q-item-section>
</q-item>
<!-- Employee List -- Supervisor, Accounting and HR only --> <q-separator spaced />
<q-item
v-ripple
clickable
side
@click="goToPageName(RouteNames.EMPLOYEE_LIST)"
v-if="['supervisor', 'accounting', 'human_resources'].includes(authStore.user.role)"
>
<q-item-section avatar>
<q-icon
name="view_list"
color="primary"
/>
</q-item-section>
<q-item-section>
<q-item-label class="text-uppercase text-weight-bold">{{ $t('nav_bar.employee_list')
}}</q-item-label>
</q-item-section>
</q-item>
<!-- Employee Timesheet temp -- Employee, Supervisor, Accounting only --> <div
<q-item class="row items-center full-width cursor-pointer q-py-sm"
v-ripple
clickable
side
@click="goToPageName(RouteNames.TIMESHEET_TEMP)"
v-if="['supervisor', 'accounting', 'employee'].includes(authStore.user.role)"
>
<q-item-section avatar>
<q-icon
name="punch_clock"
color="primary"
/>
</q-item-section>
<q-item-section>
<q-item-label class="text-uppercase text-weight-bold">{{ $t('nav_bar.timesheet')
}}</q-item-label>
</q-item-section>
</q-item>
<!-- Profile -->
<q-item
v-ripple
clickable
side
@click="goToPageName(RouteNames.PROFILE)"
>
<q-item-section avatar>
<q-icon
name="account_box"
color="primary"
/>
</q-item-section>
<q-item-section>
<q-item-label class="text-uppercase text-weight-bold">{{ $t('nav_bar.profile') }}</q-item-label>
</q-item-section>
</q-item>
<!-- Help -->
<q-item
v-ripple
clickable
@click="goToPageName('help')"
>
<q-item-section avatar>
<q-icon
name="contact_support"
color="primary"
/>
</q-item-section>
<q-item-section>
<q-item-label class="text-uppercase text-weight-bold">{{ $t('nav_bar.help') }}</q-item-label>
</q-item-section>
</q-item>
</q-list>
<!-- Logout -->
<q-item
v-ripple
clickable
@click="handleLogout" @click="handleLogout"
class="absolute-bottom"
> >
<q-item-section avatar>
<q-icon <q-icon
name="exit_to_app" name="exit_to_app"
color="primary" color="accent"
size="lg"
class="col-auto q-pl-sm"
/> />
</q-item-section>
<q-item-section> <div
<q-item-label class="text-uppercase text-weight-bold">{{ $t('nav_bar.logout') }}</q-item-label> class="col text-uppercase text-weight-bold text-h6 q-pl-sm"
</q-item-section> :class="$q.platform.is.mobile ? '' : 'q-mini-drawer-hide'"
</q-item> >
{{ $t('nav_bar.logout') }}
</div>
</div>
</q-scroll-area> </q-scroll-area>
</q-drawer> </q-drawer>
</template> </template>

View File

@ -1,20 +1,44 @@
<script lang="ts" setup> <script
import { RouterView } from 'vue-router'; lang="ts"
import HeaderBar from 'src/layouts/components/main-layout-header-bar.vue'; setup
import FooterBar from 'src/layouts/components/main-layout-footer-bar.vue'; >
import LeftDrawer from 'src/layouts/components/main-layout-left-drawer.vue'; import HeaderBar from 'src/layouts/components/main-layout-header-bar.vue';
import ChatbotPage from "src/modules/chatbot/pages/chatbot-page.vue"; import FooterBar from 'src/layouts/components/main-layout-footer-bar.vue';
import LeftDrawer from 'src/layouts/components/main-layout-left-drawer.vue';
import { onMounted, watch, ref } from 'vue';
import { RouterView } from 'vue-router';
import { useUiStore } from 'src/stores/ui-store';
const ui_store = useUiStore();
const user_preferences = ref(ui_store.user_preferences);
onMounted(async () => {
if (ui_store.user_preferences.id === -1) {
await ui_store.getUserPreferences();
}
});
watch(user_preferences, async () => {
if (ui_store.user_preferences.id !== -1) {
await ui_store.updateUserPreferences();
return
}
await ui_store.getUserPreferences();
}, {deep: true});
</script> </script>
<template> <template>
<q-layout view="hHh lpR fFf"> <q-layout view="hHh lpR fFf">
<HeaderBar /> <HeaderBar />
<LeftDrawer /> <LeftDrawer />
<q-page-container> <q-page-container>
<router-view class="q-pa-sm bg-secondary" /> <router-view />
<ChatbotPage />
</q-page-container> </q-page-container>
<FooterBar />
<FooterBar v-if="!$q.platform.is.mobile" />
</q-layout> </q-layout>
</template> </template>

View File

@ -1,21 +1,33 @@
<script setup lang="ts"> <script
import { computed, ref } from 'vue'; setup
lang="ts"
>
import { computed } from 'vue';
import { useAuthApi } from 'src/modules/auth/composables/use-auth-api'; import { useAuthApi } from 'src/modules/auth/composables/use-auth-api';
import LoginRockPaperScissor from 'src/modules/auth/components/login-rock-paper-scissor.vue';
const auth_api = useAuthApi(); const auth_api = useAuthApi();
const email = defineModel<string>('email', { default: '', }); const email = defineModel<string>('email', { default: '', });
const is_remembered = ref<boolean>(false); // const is_remembered = ref<boolean>(false);
const is_employee_email = computed( () => email.value.includes('@targ')); const is_employee_email = computed(() => email.value.includes('@targ'));
const is_game_time = computed(() => email.value.includes('allumette'));
</script> </script>
<template> <template>
<q-card class="rounded-15"> <q-card
<q-card-section class="text-center bg-primary q-pa-lg"> bordered
<q-img src="/src/assets/logo-targo-white.svg" ratio="4.6" fit="contain" /> class="rounded-15 shadow-10 full-width"
</q-card-section> >
<div class="text-center bg-primary q-pa-lg">
<q-img
src="/src/assets/logo-targo-white.svg"
ratio="4.6"
fit="contain"
/>
</div>
<div class="q-pt-sm q-px-xl q-pb-lg"> <div class="q-pt-sm q-px-xl q-pb-lg ">
<q-card-section class="text-center text-uppercase"> <q-card-section class="text-center text-uppercase">
<div class="text-h6 text-weight-bold"> <div class="text-h6 text-weight-bold">
{{ $t('login.page_header') }} {{ $t('login.page_header') }}
@ -27,17 +39,36 @@
v-model="email" v-model="email"
dense dense
outlined outlined
label-color="primary" color="accent"
:label="$t('login.email')" label-color="accent"
/> class="rounded-5 inset-shadow bg-white"
label-slot
<q-card-section class="q-ma-none q-pa-none text-uppercase text-caption text-weight-medium"> input-class="text-h6 text-primary"
>
<template #label>
<span class="text-weight-bolder text-uppercase text-overline"> {{ $t('login.email') }} </span>
</template>
</q-input>
<!-- Stay-logged-in section, removed temporarly until customer module is up -->
<!-- <q-card-section
horizontal
class="q-mb-md q-pa-none text-uppercase text-caption text-weight-medium"
>
<q-toggle <q-toggle
v-model="is_remembered" v-model="is_remembered"
color="primary" size="sm"
:label="$t('login.button.remember_me')" color="accent"
class="col-auto"
/> />
</q-card-section> <span
:key="is_remembered ? 'yep' : 'nope'"
class="col-auto text-weight-bold self-center q-ml-sm"
:class="is_remembered ? 'text-accent' : ''"
>
{{ $t('login.button.remember_me') }}
</span>
</q-card-section> -->
<q-card-actions> <q-card-actions>
<q-btn <q-btn
@ -45,9 +76,9 @@
rounded rounded
disabled disabled
type="submit" type="submit"
color="primary" color="grey-5"
:label="$t('login.button.connect')" :label="$t('login.button.connect')"
class="full-width" class="full-width q-mt-lg"
/> />
</q-card-actions> </q-card-actions>
@ -58,9 +89,18 @@
</q-form> </q-form>
<q-card-section class="row q-pt-sm"> <q-card-section class="row q-pt-sm">
<q-separator color="primary" class="col self-center"/> <q-separator
<span class="col text-primary text-weight-bolder text-center text-uppercase self-center">{{ $t('shared.misc.or') }}</span> size="2px"
<q-separator color="primary" class="col self-center"/> color="accent"
class="col self-center"
/>
<span class="col text-accent text-weight-bolder text-center text-uppercase self-center">{{
$t('shared.misc.or') }}</span>
<q-separator
size="2px"
color="accent"
class="col self-center"
/>
</q-card-section> </q-card-section>
<q-card-section class="column q-px-sm q-pt-none"> <q-card-section class="column q-px-sm q-pt-none">
@ -68,12 +108,15 @@
rounded rounded
push push
disabled disabled
color="fb-blue" color="blue-grey-7"
icon="img:src/assets/Facebook-f_Logo-White-Logo.wine.svg" icon="img:src/assets/Facebook-f_Logo-White-Logo.wine.svg"
:label="$t('login.button.facebook')" :label="$t('login.button.facebook')"
class="full-width row q-mb-sm" class="full-width row q-mb-sm"
> >
<q-tooltip anchor="top middle" class="bg-primary">{{$t('login.tooltip.coming_soon')}}</q-tooltip> <q-tooltip
anchor="top middle"
class="bg-primary"
>{{ $t('login.tooltip.coming_soon') }}</q-tooltip>
</q-btn> </q-btn>
<q-slide-transition> <q-slide-transition>
<div v-if="is_employee_email"> <div v-if="is_employee_email">
@ -85,7 +128,7 @@
<q-btn <q-btn
push push
rounded rounded
color="primary" color="accent"
icon="img:src/assets/logo-targo-simple.svg" icon="img:src/assets/logo-targo-simple.svg"
:label="$t('login.button.employee')" :label="$t('login.button.employee')"
class="full-width row" class="full-width row"
@ -97,4 +140,7 @@
</q-card-section> </q-card-section>
</div> </div>
</q-card> </q-card>
<div v-if="is_game_time">
<LoginRockPaperScissor />
</div>
</template> </template>

View File

@ -1,33 +0,0 @@
<script setup lang="ts">
import { useAuthApi } from '../composables/use-auth-api';
import { useRouter } from 'vue-router';
const auth_api = useAuthApi();
const router = useRouter();
const setBypassUser = (bypassRole: string) => {
auth_api.setUser(bypassRole);
router.push({ name: 'dashboard' }).catch( err => {
console.error('Router navigation failed: ', err);
});
};
</script>
<template>
<q-card class="absolute-bottom-right q-ma-sm">
<q-card-section class="q-pa-sm text-uppercase text-center"> impersonate </q-card-section>
<q-card-actions vertical>
<q-btn
v-for="role, index in [ 'supervisor', 'accounting', 'human_resources', 'employee' ]"
:key="index"
push
color="primary"
text-color="white"
:label="role"
class="text-uppercase"
@click="setBypassUser(role)"
/>
</q-card-actions>
</q-card>
</template>

View File

@ -0,0 +1,77 @@
<script setup lang="ts">
import { Notify } from 'quasar';
import { ref } from 'vue';
const choices = ['rock', 'paper', 'scissors'] as const;
type Choice = typeof choices[number];
const app_choice = ref<Choice>();
const click_number = ref(1);
const icon = ref('');
const color = ref('accent');
const getRandmonChoice = (): Choice => {
const index = Math.floor(Math.random() * choices.length);
return choices[index]!;
};
const icon_map: Record<Choice, string> = {
rock: 'las la-hand-rock',
paper: 'las la-hand-paper',
scissors: 'las la-hand-scissors',
};
const fightingResult = () => {
if (click_number.value === 7) {
Notify.create({
icon: 'las la-hand-middle-finger',
color: 'negative',
iconSize: '5em',
iconColor: 'white',
})
return;
}
app_choice.value = getRandmonChoice();
icon.value = icon_map[app_choice.value];
color.value = 'accent';
Notify.create({
color: color.value,
icon: icon.value,
iconSize: '5em',
iconColor: 'white',
});
click_number.value += 1;
}
</script>
<template>
<div class="row flex-center q-mt-xl">
<div class="q-px-sm">
<q-btn
push
dense
color="accent"
icon="las la-hand-rock"
@click="fightingResult()"
/>
</div>
<div class="q-px-sm">
<q-btn
push
dense
color="accent"
icon="las la-hand-paper"
@click="fightingResult()"
/>
</div>
<div class="q-px-sm">
<q-btn
push
dense
color="accent"
icon="las la-hand-scissors"
@click="fightingResult()"
/>
</div>
</div>
</template>

View File

@ -3,8 +3,6 @@ import { useAuthStore } from "../../../stores/auth-store";
export const useAuthApi = () => { export const useAuthApi = () => {
const authStore = useAuthStore(); const authStore = useAuthStore();
const login = () => { const login = () => {
authStore.login(); authStore.login();
}; };
@ -17,19 +15,9 @@ export const useAuthApi = () => {
authStore.logout(); authStore.logout();
}; };
const isAuthorizedUser = () => {
return authStore.isAuthorizedUser;
};
const setUser = (bypassRole: string) => {
authStore.setUser(bypassRole);
}
return { return {
login, login,
oidcLogin, oidcLogin,
logout, logout,
isAuthorizedUser,
setUser,
}; };
}; };

View File

@ -1,4 +1,7 @@
<script setup lang="ts"> <script
setup
lang="ts"
>
import { onMounted } from 'vue'; import { onMounted } from 'vue';
onMounted(() => { onMounted(() => {
@ -6,7 +9,7 @@
setTimeout(() => { setTimeout(() => {
window.opener.postMessage({ type: 'authSuccess' }); window.opener.postMessage({ type: 'authSuccess' });
window.close(); window.close();
}, 1500); }, 2000);
} }
}); });
</script> </script>
@ -15,14 +18,31 @@
<q-layout class="bg-secondary"> <q-layout class="bg-secondary">
<q-page-container> <q-page-container>
<q-page class="column items-center justify-center q-pa-xl"> <q-page class="column items-center justify-center q-pa-xl">
<transition appear enter-active-class="animated slow flipInX" leave-active-class="animated flipOutX"> <transition
<q-card class="col-3 items-center"> appear
enter-active-class="animated flipInX"
>
<q-card class="col-3 items-center rounded-10 q-px-lg">
<q-card-section class="row justify-center "> <q-card-section class="row justify-center ">
<q-icon name="check_circle" color="green" size="xl" /> <q-icon
name="check_circle"
color="accent"
size="xl"
/>
</q-card-section> </q-card-section>
<q-separator inset color="primary" /> <q-separator
<q-card-section class="row justify-center"> inset
<span class="row text-primary text-h3">Login Successful!</span> color="accent"
/>
<q-card-section class="column items-center">
<span class="col-auto text-h4 text-uppercase">{{ $t('login.connected') }}</span>
<span class="col-auto text-h6 text-uppercase">{{ $t('login.redirecting') }}</span>
<q-spinner
color="accent"
size="5em"
:thickness="4"
class="col-auto"
/>
</q-card-section> </q-card-section>
</q-card> </q-card>
</transition> </transition>

View File

@ -1,20 +0,0 @@
<script setup lang="ts">
import LoginConnectionPanel from 'src/modules/auth/components/login-connection-panel.vue';
import LoginDevBypass from 'src/modules/auth/components/login-dev-bypass.vue';
</script>
<template>
<q-layout view="hHh lpR fFf">
<q-page-container class="bg-dark">
<q-img src="src/assets/village.png" fit="cover" position="50% 100%" class="absolute-full" />
<q-page class="flex flex-center">
<transition appear slow enter-active-class="animated zoomIn" leave-active-class="animated zoomOut">
<LoginConnectionPanel />
</transition>
<!-- DEV TOOLS -->
<LoginDevBypass />
</q-page>
</q-page-container>
</q-layout>
</template>

View File

@ -1,5 +1,6 @@
/* eslint-disable */ /* eslint-disable */
import { api } from 'src/boot/axios'; import { api } from 'src/boot/axios';
import type { User } from 'src/modules/shared/models/user.models';
export const AuthService = { export const AuthService = {
// Will likely be deprecated and relegated to Authentik // Will likely be deprecated and relegated to Authentik
@ -7,16 +8,6 @@ export const AuthService = {
//TODO: OIDC customer sign-in, eventually //TODO: OIDC customer sign-in, eventually
}, },
oidcLogin: (): Window | null => {
window.addEventListener('message', (event) => {
if (event.data.type === 'authSuccess') {
//some kind of logic here to set user in store
}
})
return window.open('http://localhost:3000/auth/v1/login', 'authPopup', 'width=600,height=800');
},
logout: () => { logout: () => {
// TODO: logout logic // TODO: logout logic
api.post('/auth/logout') api.post('/auth/logout')
@ -27,8 +18,8 @@ export const AuthService = {
api.post('/auth/refresh') api.post('/auth/refresh')
}, },
getProfile: () => { getProfile: async (): Promise<User> => {
// TODO: user info fetch logic const response = await api.get('/auth/me');
api.get('/auth/me') return response.data;
}, },
}; };

View File

@ -0,0 +1,42 @@
<script
setup
lang="ts"
>
const { imageSource = "", title = "", description = "", route = "" } = defineProps<{
imageSource?: string,
title?: string,
description?: string,
route?: string,
}>();
const onClickExternalShortcut = () => {
window.open(route, '_blank')?.focus();
}
</script>
<template>
<q-card
class="shortcut-card cursor-pointer shadow-12"
@click="onClickExternalShortcut"
>
<q-img
:src="imageSource"
fit="contain"
>
<div class="absolute-bottom text-uppercase text-weight-bolder text-center">{{ title }}</div>
</q-img>
<q-card-section v-if="description">
<span>{{ description }}</span>
</q-card-section>
</q-card>
</template>
<style
lang="sass"
scoped
>
.shortcut-card
width: 100%
max-width: 250px
</style>

View File

@ -0,0 +1,72 @@
<script
setup
lang="ts"
>
import { RouteNames } from 'src/router/router-constants';
import { ref } from 'vue';
const slide = ref<string>('welcome');
</script>
<template>
<q-carousel
v-model="slide"
transition-prev="jump-right"
transition-next="jump-left"
swipeable
animated
infinite
arrows
:autoplay="9001"
control-color="accent"
control-type="outline"
class="bg-dark full-width rounded-15 shadow-18"
>
<!-- welcome slide -->
<q-carousel-slide
name="welcome"
class="q-pa-none fit"
>
<div class="column fit">
<q-img
src="src/assets/targo_building.png"
position="50% 25%"
fit="cover"
class="col-9"
>
<div class="absolute-bottom text-h6 text-uppercase text-weight-light">
{{ $t('dashboard.carousel.welcome_title') }}
</div>
</q-img>
<div class="col column flex-center q-px-md">
<span class="col-auto text-center">{{ $t('dashboard.carousel.welcome_message') }}</span>
</div>
</div>
</q-carousel-slide>
<!-- help page slide -->
<q-carousel-slide
name="tv"
class="q-pa-none cursor-pointer"
@click="$router.push(RouteNames.HELP)"
>
<div class="column fit">
<q-img
src="src/assets/targo_help_banner.png"
position="50% 25%"
fit="none"
class="col-9"
>
<div class="absolute-bottom text-h6 text-uppercase text-weight-light">
{{ $t('dashboard.carousel.help_title') }}
</div>
</q-img>
<div class="col column flex-center q-px-md">
<span class="col-auto text-center">{{ $t('dashboard.carousel.help_message') }}</span>
</div>
</div>
</q-carousel-slide>
</q-carousel>
</template>

View File

@ -0,0 +1,194 @@
<script
setup
lang="ts"
>
import { ref } from 'vue';
import { unwrapAndClone } from 'src/utils/unwrap-and-clone';
import { useEmployeeStore } from 'src/stores/employee-store';
import { employee_access_options, type ModuleAccessPreset, employee_access_presets, getEmployeeAccessOptionIcon } from 'src/modules/employee-list/models/employee-profile.models';
import type { UserModuleAccess } from 'src/modules/shared/models/user.models';
const employee_store = useEmployeeStore();
const preset_preview = ref<ModuleAccessPreset>();
const toggleInSelected = (value: UserModuleAccess) => {
const i = employee_store.employee.user_module_access.indexOf(value);
if (i === -1) employee_store.employee.user_module_access.push(value);
else employee_store.employee.user_module_access.splice(i, 1);
}
const applyAccessPreset = (preset: ModuleAccessPreset) => {
employee_store.employee.user_module_access = unwrapAndClone(employee_access_presets[preset]);
}
const getPreviewBackgroundColor = (name: UserModuleAccess) => {
if (employee_access_presets[preset_preview.value!].includes(name)) {
if (!employee_store.employee.user_module_access.includes(name)) return 'bg-info text-white';
return 'bg-accent text-white';
}
if (employee_store.employee.user_module_access.includes(name)) return 'bg-negative text-white';
return 'bg-dark';
};
const getBackgroundColor = (name: UserModuleAccess) => {
if (employee_store.employee.user_module_access.includes(name)) return 'bg-accent text-white';
return 'bg-dark';
};
</script>
<template>
<div class="column full-width items-start content-start overflow-hidden-y">
<!-- warning when all modules are disabled -->
<div class="col-auto row flex-center q-px-lg q-py-xs full-width">
<q-slide-transition>
<div
v-if="employee_store.employee.user_module_access.length === 0"
class="row flex-center q-px-md q-py-xs bg-dark"
style="border: 2px solid var(--q-warning);"
>
<q-icon
name="las la-exclamation-triangle"
color="warning"
size="sm"
/>
<span class="text-warning text-weight-medium q-px-sm">{{ $t('employee_list.errors.no_modules_warning') }}</span>
<q-icon
name="las la-exclamation-triangle"
color="warning"
size="sm"
/>
</div>
</q-slide-transition>
</div>
<!-- info line explaining how to customize access -->
<div class="col-auto row flex-center q-px-sm q-py-xs no-wrap">
<q-icon
name="info_outline"
size="sm"
color="accent"
class="col-auto q-mr-sm"
/>
<q-item-label
caption
class="col-auto text-weight-medium"
>{{ $t('employee_management.module_access.usage_description') }}</q-item-label>
</div>
<div
class="col"
:class="$q.platform.is.mobile ? 'column' : 'row'"
>
<!-- column to attribute access by roles -->
<div class="column col-4 overflow-hidden-y q-pr-sm">
<span class="text-uppercase text-weight-medium q-mx-sm">
{{ $t('employee_management.module_access.by_role') }}
</span>
<q-separator
size="2px"
color="accent"
class="q-mx-sm"
style="transform: translateY(-4px);"
/>
<q-item
clickable
class="shadow-2 rounded-5 q-ma-sm bg-dark"
@click="applyAccessPreset('admin')"
@mouseover="preset_preview = 'admin'"
@mouseleave="preset_preview = undefined"
>
<q-item-section>
<q-item-label class="text-uppercase text-weight-bold">
{{ $t('employee_management.module_access.preset_admin') }}
</q-item-label>
<q-item-label caption>
{{ $t('employee_management.module_access.admin_description') }}
</q-item-label>
</q-item-section>
</q-item>
<q-item
clickable
class="shadow-2 rounded-5 q-ma-sm bg-dark"
@click="applyAccessPreset('employee')"
@mouseover="preset_preview = 'employee'"
@mouseleave="preset_preview = undefined"
>
<q-item-section>
<q-item-label class="text-uppercase text-weight-bold">
{{ $t('employee_management.module_access.preset_employee') }}
</q-item-label>
<q-item-label caption>
{{ $t('employee_management.module_access.employee_description') }}
</q-item-label>
</q-item-section>
</q-item>
<q-btn
flat
color="negative"
icon="clear"
size="md"
align="left"
:label="$t('employee_management.module_access.uncheck_all')"
class="q-ma-sm q-px-xs rounded-5"
@click="applyAccessPreset('none')"
@mouseover="preset_preview = 'none'"
@mouseleave="preset_preview = undefined"
/>
</div>
<div class="row col items-start content-start q-pl-sm">
<div class="col-12 q-mb-xs">
<span class="text-uppercase text-weight-medium q-mx-sm">
{{ $t('employee_management.module_access.by_module') }}
</span>
<q-separator
size="2px"
color="accent"
class="q-mx-sm"
style="transform: translateY(-4px);"
/>
</div>
<div
v-for="option in employee_access_options"
:key="option.label"
class="col-lg-6 col-sm-12 col-xs-12 q-pa-xs"
>
<div
class="row full-width cursor-pointer flex-center q-pa-sm rounded-5 no-wrap shadow-5"
:class="preset_preview !== undefined ? getPreviewBackgroundColor(option.value) : getBackgroundColor(option.value)"
@click="toggleInSelected(option.value)"
>
<q-icon
:name="getEmployeeAccessOptionIcon(option.value)"
size="sm"
class="q-mr-sm"
/>
<span class="text-uppercase text-weight-bold non-selectable">
{{ $t('employee_management.module_access.' + option.value) }}
</span>
<q-space />
<q-icon
:name="employee_store.employee.user_module_access.includes(option.value) ? 'check' : ''"
size="sm"
/>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,58 @@
<script
setup
lang="ts"
>
const model = defineModel<string | number | null | undefined>({ required: true });
const is_date_picker_open = defineModel<boolean>('isDatePickerOpen', {default: false});
defineProps<{
label?: string | undefined;
requiresDatePicker?: boolean | undefined;
}>();
</script>
<template>
<q-input
v-model="model"
dense
outlined
color="accent"
stack-label
label-slot
no-error-icon
hide-bottom-space
class="col q-mx-sm q-my-xs rounded-5 bg-dark shadow-12"
>
<template #label>
<span
class="text-weight-bolder text-uppercase"
style="font-size: 0.85em;"
>
{{ label }}
</span>
</template>
<template #append v-if="requiresDatePicker">
<q-btn
flat
dense
size="lg"
icon="calendar_month"
color="accent"
@click="is_date_picker_open = true"
>
<q-dialog
v-model="is_date_picker_open"
backdrop-filter="none"
>
<q-date
v-model="model"
mask="YYYY-MM-DD"
color="accent"
@update:model-value="is_date_picker_open = false"
/>
</q-dialog>
</q-btn>
</template>
</q-input>
</template>

View File

@ -0,0 +1,40 @@
<script
setup
lang="ts"
>
const model = defineModel<string>({ required: true });
defineProps<{
label?: string | undefined;
}>();
</script>
<template>
<q-select
v-model="model"
dense
outlined
color="accent"
stack-label
label-slot
lazy-rules
no-error-icon
hide-bottom-space
options-selected-class="text-white text-bold bg-accent"
class="col q-mx-sm q-my-xs bg-dark rounded-5 shadow-12"
popup-content-class="text-uppercase text-weight-medium rounded-5"
popup-content-style="border: 2px solid var(--q-accent)"
menu-anchor="bottom middle"
menu-self="top middle"
:menu-offset="[0, 5]"
>
<template #label>
<span
class="text-weight-bolder text-uppercase"
style="font-size: 0.85em;"
>
{{ label }}
</span>
</template>
</q-select>
</template>

View File

@ -0,0 +1,210 @@
<script
setup
lang="ts"
>
import AddModifyDialogFormInput from 'src/modules/employee-list/components/add-modify-dialog-form-input.vue';
import AddModifyDialogFormSelect from 'src/modules/employee-list/components/add-modify-dialog-form-select.vue';
import { ref, computed } from 'vue';
import { useEmployeeStore } from 'src/stores/employee-store';
import { type QuasarRules, useEmployeeProfileRules, company_options } from 'src/modules/employee-list/employee-list-utils';
const employee_store = useEmployeeStore();
const last_work_day = computed(() => employee_store.employee.last_work_day ?? '---');
const is_first_day_picker_open = ref(false);
const is_last_day_picker_open = ref(false);
const form_rules = useEmployeeProfileRules();
const supervisor_options = computed(() => {
const supervisors = employee_store.employee_list.filter(employee => employee.is_supervisor === true && employee.last_work_day === null);
return supervisors.map(supervisor => supervisor.first_name + ' ' + supervisor.last_name);
})
const setLastWorkDay = (date: string | number | null | undefined) => {
if (typeof date === 'string' && date.length > 0) {
employee_store.employee.last_work_day = date;
}
employee_store.employee.last_work_day = null;
}
</script>
<template>
<div>
<q-form>
<div class="row flex-center">
<transition
enter-active-class="animated pulse fast"
mode="out-in"
>
<q-checkbox
v-model="employee_store.employee.is_supervisor"
:key="employee_store.employee.is_supervisor ? '1' : '0'"
dense
left-label
:label="$t('employee_list.table.is_supervisor')"
size="lg"
color="accent"
class="col-auto text-uppercase q-py-xs q-px-lg q-ma-xs rounded-25"
:class="employee_store.employee.is_supervisor ? 'bg-accent text-white text-weight-bold' : ''"
/>
</transition>
</div>
<div
class="q-ma-xs"
:class="$q.screen.lt.sm ? 'column' : 'row'"
>
<AddModifyDialogFormInput
v-model="employee_store.employee.first_name"
:label="$t('profile.personal.first_name')"
:rules="[(value: string, rules: QuasarRules) => form_rules.isNotEmpty(value, rules, $t('employee_list.errors.first_name_required'))]"
/>
<AddModifyDialogFormInput
v-model="employee_store.employee.last_name"
:label="$t('profile.personal.last_name')"
:rules="[(value: string, rules: QuasarRules) => form_rules.isNotEmpty(value, rules, $t('employee_list.errors.last_name_required'))]"
/>
</div>
<div
class="q-ma-xs"
:class="$q.screen.lt.sm ? 'column' : 'row'"
>
<AddModifyDialogFormInput
v-model="employee_store.employee.email"
:label="$t('profile.employee.email')"
/>
<AddModifyDialogFormInput
v-model="employee_store.employee.phone_number"
:label="$t('profile.personal.phone_number')"
mask="(###) ### - ####"
:rules="[(value: string, rules: QuasarRules) => form_rules.isNotEmpty(value, rules, $t('employee_list.errors.phone_number_required'))]"
/>
</div>
<div
class="q-ma-xs"
:class="$q.screen.lt.sm ? 'column' : 'row'"
>
<AddModifyDialogFormInput
v-model="employee_store.employee.job_title"
:label="$t('profile.employee.job_title')"
/>
<AddModifyDialogFormSelect
v-model="employee_store.employee.company_name"
:options="company_options"
:label="$t('profile.employee.company')"
:rules="[(value: string, rules: QuasarRules) => form_rules.isNotEmpty(value, rules, $t('employee_list.errors.company_required'))]"
/>
</div>
<div
class="q-ma-xs"
:class="$q.screen.lt.sm ? 'column' : 'row'"
>
<AddModifyDialogFormSelect
v-model="employee_store.employee.supervisor_full_name"
:options="supervisor_options"
:label="$t('profile.employee.supervisor')"
/>
<AddModifyDialogFormInput
v-model="employee_store.employee.external_payroll_id"
:label="$t('profile.employee.bankroll_id')"
:placeholder="$t('employee_management.can_be_entered_later')"
/>
</div>
<div
class="q-ma-xs"
:class="$q.screen.lt.sm ? 'column' : 'row'"
>
<AddModifyDialogFormInput
v-model="employee_store.employee.daily_expected_hours"
:label="$t('employee_list.table.expected_daily_hours')"
:rules="[(value: string, rules: QuasarRules) => form_rules.isNotEmpty(value, rules, $t('employee_list.errors.daily_hours_required'))]"
type="number"
/>
<AddModifyDialogFormInput
v-if="employee_store.employee.paid_time_off"
v-model="employee_store.employee.paid_time_off.banked_hours"
:label="$t('employee_management.banked_hours')"
type="number"
/>
<div
v-else
class="col q-px-sm"
></div>
</div>
<div
class="q-ma-xs"
:class="$q.screen.lt.sm ? 'column' : 'row'"
>
<AddModifyDialogFormInput
v-if="employee_store.employee.paid_time_off"
v-model="employee_store.employee.paid_time_off.sick_hours"
:label="$t('employee_management.sick_hours')"
type="number"
@update:model-value="employee_store.employee.paid_time_off.last_updated = new Date().toISOString().slice(0, 10)"
/>
<AddModifyDialogFormInput
v-if="employee_store.employee.paid_time_off"
v-model="employee_store.employee.paid_time_off.vacation_hours"
:label="$t('employee_management.vacation_hours')"
type="number"
/>
</div>
<div
class="q-ma-xs"
:class="$q.screen.lt.md ? 'column' : 'row'"
>
<AddModifyDialogFormInput
v-model="employee_store.employee.first_work_day"
v-model:is-date-picker-open="is_first_day_picker_open"
reqires-date-picker
:label="$t('profile.employee.hired_date')"
:rules="[(value: string, rules: QuasarRules) => form_rules.isNotEmpty(value, rules, $t('employee_list.errors.hire_date_required'))]"
mask="####-##-##"
/>
<AddModifyDialogFormInput
v-model="last_work_day"
v-model:is-date-picker-open="is_last_day_picker_open"
reqires-date-picker
:label="$t('profile.employee.fired_date')"
mask="####-##-##"
@update:model-value="setLastWorkDay"
/>
</div>
</q-form>
</div>
</template>
<style scoped>
:deep(.q-field--error .q-field__bottom) {
color: white;
font-weight: 500;
text-transform: uppercase;
border-radius: 0 0 5px 5px;
padding-top: 0;
align-items: center;
background-color: var(--q-negative);
}
:deep(.row > .col) {
height: fit-content;
}
:deep(.q-field--outlined.q-field--highlighted .q-field__control::after) {
border-radius: 5px 5px 0 0;
}
</style>

View File

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

View File

@ -0,0 +1,122 @@
<script
setup
lang="ts"
>
import HorizontalSlideTransition from 'src/modules/shared/components/horizontal-slide-transition.vue';
import SchedulePresetsDialog from 'src/modules/employee-list/components/schedule-presets-dialog.vue';
import AddModifyDialogSchedulePreview from './add-modify-dialog-schedule-preview.vue';
import { onMounted, ref, watch } from 'vue';
import { useSchedulePresetsStore } from 'src/stores/schedule-presets.store';
import { useEmployeeStore } from 'src/stores/employee-store';
import { useEmployeeListApi } from '../composables/use-employee-api';
import type { PresetManagerMode } from 'src/modules/employee-list/models/schedule-presets.models';
const schedule_preset_store = useSchedulePresetsStore();
const employee_store = useEmployeeStore();
const employee_list_api = useEmployeeListApi();
const preset_options = ref<{ label: string, value: number }[]>([]);
const current_preset = ref<{ label: string | undefined, value: number }>({ label: undefined, value: -1 });
const manager_watcher = ref(schedule_preset_store.is_manager_open);
const getPresetOptions = (): { label: string, value: number }[] => {
const options = schedule_preset_store.schedule_presets.map(preset => { return { label: preset.name, value: preset.id } });
options.push({ label: 'Aucun', value: -1 });
return options;
};
const onClickSchedulePresetManager = (mode: PresetManagerMode, preset_id?: number) => {
schedule_preset_store.schedule_preset_dialog_mode = mode;
schedule_preset_store.openSchedulePresetManager(preset_id ?? current_preset.value.value);
}
const loadSelectedPresetOption = () => {
preset_options.value = getPresetOptions();
const current_option = preset_options.value.find(option => option.value === employee_store.employee.preset_id);
current_preset.value = current_option ?? { label: undefined, value: -1 };
schedule_preset_store.setCurrentSchedulePreset(current_preset.value.value);
};
onMounted(() => {
loadSelectedPresetOption();
});
watch(manager_watcher, loadSelectedPresetOption)
</script>
<template>
<div
:key="schedule_preset_store.is_manager_open === false ? '0' : '1'"
class="column full-width flex-center items-start"
>
<SchedulePresetsDialog />
<div class="col row justify-center full-width no-wrap">
<q-select
v-model="current_preset"
standout="bg-accent"
dense
options-dense
rounded
color="accent"
:options="getPresetOptions()"
class="col-xs-10 col-md-7"
popup-content-class="text-uppercase text-weight-medium rounded-20"
popup-content-style="border: 2px solid var(--q-accent)"
menu-anchor="bottom middle"
menu-self="top middle"
:menu-offset="[0, 10]"
@update:modelValue="option => employee_list_api.setSchedulePreset(option.value)"
>
<template #selected>
<span
class="text-uppercase text-center text-weight-bold full-width"
:style="current_preset.label === undefined ? 'opacity: 0.5;' : ''"
>
{{ current_preset.label === undefined ?
$t('employee_management.schedule_presets.preset_list_placeholder') :
current_preset.label }}
</span>
</template>
</q-select>
<q-btn
icon="add"
color="accent"
class="col-auto q-px-sm q-ml-sm rounded-50"
@click="onClickSchedulePresetManager('create', -1)"
/>
<HorizontalSlideTransition :show="current_preset !== undefined && current_preset?.value !== -1">
<div class="col-auto row no-wrap full-height">
<q-btn
icon="edit"
color="accent"
class="col-auto q-px-sm q-ml-sm rounded-50"
@click="onClickSchedulePresetManager('update')"
/>
<q-btn
icon="content_copy"
color="accent"
class="col-auto q-px-sm q-mx-sm rounded-50"
@click="onClickSchedulePresetManager('copy')"
/>
<q-btn
flat
dense
rounded
icon="clear"
color="negative"
class="col-auto q-px-sm full-height"
@click="onClickSchedulePresetManager('delete')"
/>
</div>
</HorizontalSlideTransition>
</div>
<AddModifyDialogSchedulePreview :current-preset-id="current_preset.value" />
</div>
</template>

View File

@ -0,0 +1,119 @@
<script
setup
lang="ts"
>
import AddModifyDialogForm from 'src/modules/employee-list/components/add-modify-dialog-form.vue';
import AddModifyDialogAccess from 'src/modules/employee-list/components/add-modify-dialog-access.vue';
import AddModifyDialogSchedule from 'src/modules/employee-list/components/add-modify-dialog-schedule.vue';
import { ref } from 'vue';
import { useEmployeeStore } from 'src/stores/employee-store';
import { EmployeeProfile } from 'src/modules/employee-list/models/employee-profile.models';
const employee_store = useEmployeeStore();
const current_step = ref<'form' | 'access' | 'schedule'>('form');
const initial_employee_profile = ref(new EmployeeProfile)
</script>
<template>
<q-dialog
v-model="employee_store.is_add_modify_dialog_open"
full-width
full-height
@beforeShow="current_step = 'form'"
@show="Object.assign(initial_employee_profile, employee_store.employee)"
class="shadow-24"
>
<div
class="column bg-secondary rounded-10 no-wrap"
:class="$q.dark.isActive ? 'shadow-24' : 'shadow-10'"
:style="($q.screen.lt.md ? ' ' : 'max-width: 60vw !important; max-height: 80vh !important;') +
($q.dark.isActive ? 'border: 2px solid var(--q-accent)' : '')"
>
<div class="row col-auto text-white bg-primary flex-center shadow-5">
<div class="q-py-sm text-uppercase text-weight-bolder text-h5 ">
{{ $t('employee_management.' + employee_store.management_mode) }}
</div>
<div
v-if="employee_store.employee.first_name.length > 0"
class="text-uppercase text-weight-light text-h6 q-ml-sm"
>
{{ `${employee_store.employee.first_name} ${employee_store.employee.last_name}` }}
</div>
</div>
<div class="col column q-pa-md no-wrap scroll">
<q-tabs
v-model="current_step"
dense
inline-label
align="justify"
indicator-color="transparent"
active-class="text-white bg-accent"
class="q-mb-sm"
>
<q-tab
name="form"
icon="las la-id-card"
:label="$q.screen.lt.sm ? '' : $t('employee_management.details_label')"
class="rounded-25 q-ma-xs bg-dark"
style="border: 2px solid var(--q-accent);"
/>
<q-tab
name="access"
icon="las la-key"
:label="$q.screen.lt.sm ? '' : $t('employee_management.access_label')"
class="rounded-25 q-ma-xs bg-dark"
style="border: 2px solid var(--q-accent);"
/>
<q-tab
name="schedule"
icon="calendar_month"
:label="$q.screen.lt.sm ? '' : $t('employee_management.schedule_label')"
class="rounded-25 q-ma-xs bg-dark"
style="border: 2px solid var(--q-accent);"
/>
</q-tabs>
<q-tab-panels
v-model="current_step"
animated
:transition-prev="$q.screen.lt.sm ? 'jump-down' : 'jump-right'"
:transition-next="$q.screen.lt.sm ? 'jump-up' : 'jump-left'"
class="bg-transparent full-height"
>
<q-tab-panel
name="form"
class="q-pa-xs"
>
<AddModifyDialogForm />
</q-tab-panel>
<q-tab-panel
name="access"
class="q-pa-xs"
>
<AddModifyDialogAccess />
</q-tab-panel>
<q-tab-panel
name="schedule"
class="q-pa-xs"
>
<AddModifyDialogSchedule />
</q-tab-panel>
</q-tab-panels>
</div>
<q-btn
square
color="accent"
:label="employee_store.management_mode === 'add_employee' ? $t('shared.label.save') : $t('shared.label.update')"
class="col-auto q-py-sm shadow-up-5"
@click="employee_store.createOrUpdateEmployee(employee_store.employee)"
/>
</div>
</q-dialog>
</template>

View File

@ -0,0 +1,107 @@
<script
setup
lang="ts"
>
import { useQuasar } from 'quasar';
import type { EmployeeProfile } from 'src/modules/employee-list/models/employee-profile.models';
import { ref } from 'vue';
const q = useQuasar();
const is_mouseover = ref(false);
const { row, index = -1, isManagement = false } = defineProps<{
row: EmployeeProfile
index?: number
isManagement?: boolean;
}>()
defineEmits<{
onProfileClick: [email: string]
}>();
const getItemStyle = (): string => {
const active_style = row.last_work_day === null ? '' : 'opacity: 0.6;';
const dark_style = q.dark.isActive ? 'border: 2px solid var(--q-accent);' : '';
const hover_style = isManagement ? (is_mouseover.value ? `transform: scale(1.1); z-index: 2;` : 'transform: scale(1) skew(0)') : '';
return `${active_style} ${dark_style} ${hover_style}`;
}
</script>
<template>
<div
class="col-xs-6 col-sm-4 col-md-3 col-lg-3 col-xl-2 q-pa-sm row flex-center"
:style="`animation-delay: ${index / 25}s;`"
>
<div
class="column col no-wrap bg-dark rounded-15 shadow-12"
:class="isManagement ? 'cursor-pointer item-mouse-hover' : ''"
style="height: 275px;"
:style="getItemStyle()"
@click="$emit('onProfileClick', row.email)"
@mouseenter="is_mouseover = true"
@mouseleave="is_mouseover = false"
>
<div class="col-auto column flex-center q-pt-md">
<q-avatar
:color="row.last_work_day === null ? 'accent' : 'negative'"
size="8em"
class="col-auto shadow-3"
>
<img
src="src/assets/targo-default-avatar.png"
alt="employee avatar"
class="q-pa-xs"
>
</q-avatar>
</div>
<div
class="col column items-center justify-start text-center text-weight-medium text-uppercase q-px-sm q-pt-sm no-wrap"
style="line-height: 1.2em; font-size: 1.3em;"
>
<div
class="ellipsis-2-lines"
:class="row.last_work_day === null ? 'text-accent' : 'text-negative'"
>
{{ row.first_name }} {{ row.last_name }}
<q-separator class="q-mb-xs q-mx-md" />
</div>
<div class="col-auto ellipsis-2-lines text-caption no-wrap">{{ row.job_title }}</div>
</div>
<div
class="col-auto column items-center bg-primary text-white text-caption text-center q-py-xs"
style="border-radius: 0 0 15px 15px;"
>
<div
class="col-auto row flex-center text-weight-light text-italic"
style="font-size: 1em;"
>
<q-icon
name="las la-phone"
size="xs"
color="accent"
class="col-auto"
/>
<span class="col-auto">{{ row.phone_number }}</span>
</div>
<span class="col-auto text-italic">extension: </span>
<span class="col-auto text-weight-medium">{{ row.email }}</span>
</div>
</div>
</div>
</template>
<style
lang="css"
scoped
>
.item-mouse-hover {
transition: all 0.2s ease-out;
}
</style>

View File

@ -0,0 +1,300 @@
<script
setup
lang="ts"
>
import EmployeeListTableItem from 'src/modules/employee-list/components/employee-list-table-item.vue';
import { onMounted, ref } from 'vue';
import { date, type QTableColumn } from 'quasar';
import { useUiStore } from 'src/stores/ui-store';
import { useAuthStore } from 'src/stores/auth-store';
import { useEmployeeStore } from 'src/stores/employee-store';
import { useTimesheetStore } from 'src/stores/timesheet-store';
import { employee_list_columns, type EmployeeProfile, type EmployeeListFilters } from 'src/modules/employee-list/models/employee-profile.models';
const ui_store = useUiStore();
const auth_store = useAuthStore();
const employee_store = useEmployeeStore();
const timesheet_store = useTimesheetStore();
const is_management = auth_store.user?.user_module_access.includes('employee_management') ?? false;
const visible_columns = ref<(keyof EmployeeProfile)[]>(['first_name', 'email', 'job_title', 'phone_number', 'last_work_day']);
const table_grid_container = ref<HTMLElement | null>(null);
const filters = ref<EmployeeListFilters>({
search_bar_string: '',
hide_inactive_users: true,
});
const { maxHeight } = defineProps<{
maxHeight: number;
}>();
const filterEmployeeRows = (rows: readonly EmployeeProfile[], terms: EmployeeListFilters, _cols: readonly QTableColumn<EmployeeProfile>[]): EmployeeProfile[] => {
let result = [...rows];
if (terms.hide_inactive_users) {
const now = new Date();
result = result.filter(row => {
if (!row.last_work_day) return true;
const inactiveDate = date.extractDate(row.last_work_day, 'YYYY-MM-DD');
const limit = new Date(inactiveDate);
limit.setDate(limit.getDate() + 14);
return limit >= now;
});
}
if (terms.search_bar_string.trim().length > 0) {
const searchTerms = terms.search_bar_string.split(' ').map(s => s.trim().toLowerCase());
result = result.filter(row => {
const row_values = Object.values(row).map(v => String(v ?? '').toLowerCase());
const row_values_without_emails = row_values.filter(value => !value.includes('@'));
return searchTerms.every(term =>
row_values_without_emails.some(value => value.includes(term))
);
});
}
return result;
};
onMounted(() => {
table_grid_container.value = document.querySelector(".q-table__grid-content") as HTMLElement;
})
</script>
<template>
<div class="full-width">
<q-table
:key="filters.hide_inactive_users ? '1' : '0'"
dense
hide-pagination
title=" "
card-style="max-height: 70vh;"
:rows="employee_store.employee_list"
:columns="employee_list_columns"
row-key="email"
:rows-per-page-options="[0]"
:pagination="{ sortBy: 'first_name' }"
:filter="filters"
:filter-method="filterEmployeeRows"
class="bg-transparent no-shadow sticky-header-table full-width q-pt-lg"
:style="employee_store.employee_list.length > 0 ? `max-height: ${maxHeight - (ui_store.user_preferences.is_employee_list_grid ? 0 : 20)}px;` : ''"
:table-class="$q.dark.isActive ? 'q-py-none q-mx-md rounded-10 bg-dark shadow-10 hide-scrollbar' : 'q-py-none q-mx-md rounded-10 bg-white shadow-10 hide-scrollbar'"
color="accent"
separator="none"
table-header-class="text-accent text-uppercase"
card-container-class="justify-center"
:grid="ui_store.user_preferences.is_employee_list_grid"
:loading="employee_store.is_loading"
:no-data-label="$t('shared.error.no_data_found')"
:no-results-label="$t('shared.error.no_search_results')"
:loading-label="$t('shared.label.loading')"
:visible-columns="visible_columns"
>
<template #top>
<div class="row flex-center full-width q-mb-sm">
<q-btn
v-if="is_management"
rounded
color="accent"
icon="las la-user-edit"
:label="$t('shared.label.add')"
class="text-uppercase q-py-sm"
@click.stop="_evt => employee_store.openAddModifyDialog()"
/>
<q-checkbox
v-if="is_management"
v-model="filters.hide_inactive_users"
color="accent"
:label="$t('employee_management.filter.hide_terminated')"
class="text-uppercase q-ml-md text-weight-medium q-px-sm"
>
<q-icon
name="las la-user-times"
color="negative"
size="sm"
class="q-px-sm"
/>
</q-checkbox>
<q-space />
<q-btn-toggle
v-model="ui_store.user_preferences.is_employee_list_grid"
push
rounded
color="white"
text-color="accent"
toggle-color="accent"
class="q-mr-md"
:options="[
{ icon: 'grid_view', value: true },
{ icon: 'view_list', value: false },
]"
/>
<q-input
v-model="filters.search_bar_string"
outlined
dense
rounded
color="accent"
bg-color="white"
label-color="accent"
debounce="300"
:label="$t('shared.label.search')"
>
<template v-slot:append>
<q-icon
name="search"
color="accent"
/>
</template>
</q-input>
</div>
</template>
<template #header="props">
<q-tr :props="props">
<q-th
v-for="col in props.cols"
:key="col.name"
:props="props"
>
<span
class="text-uppercase text-weight-bolder text-white"
style="font-size: 1.2em;"
>
{{ $t(col.label) }}
</span>
</q-th>
</q-tr>
</template>
<template #item="props">
<transition
appear
enter-active-class="animated zoomIn fast"
leave-active-class="animated zoomOut fast"
mode="out-in"
>
<EmployeeListTableItem
:key="props.rowIndex"
:row="props.row"
:index="props.rowIndex"
:is-management="is_management"
@on-profile-click="email => is_management ? employee_store.openAddModifyDialog(email) : ''"
/>
</transition>
</template>
<template #body-cell="scope">
<q-td
:props="scope"
@click="is_management ? employee_store.openAddModifyDialog(scope.row.email) : ''"
:class="scope.rowIndex % 2 === 0 ? ($q.dark.isActive ? 'bg-primary' : 'bg-secondary') : ''"
>
<transition
appear
enter-active-class="animated fadeInUp slow"
leave-active-class="animated fadeOutDown faster"
mode="out-in"
>
<div
:key="scope.rowIndex + (timesheet_store.pay_period?.pay_period_no ?? 0)"
class="rounded-5 cursor-pointer"
style="font-size: 1.2em;"
:style="scope.row.last_work_day === null ? '' : 'opacity: 0.5;'"
>
<div v-if="scope.col.name === 'first_name'">
<span
class="text-h5 text-uppercase q-mr-xs"
:class="scope.row.last_work_day === null ? 'text-accent' : 'text-negative'"
>{{ scope.value }}</span>
<span class="text-uppercase text-weight-light">{{ scope.row.last_name }}</span>
</div>
<div v-else-if="scope.col.name === 'last_work_day'">
<q-badge
:color="scope.row.last_work_day === null ? 'accent' : 'negative'"
class="row rounded-50 q-px-sm self-center"
>
<span class="text-bold text-uppercase q-mr-sm">
{{ scope.row.last_work_day === null ? $t('employee_list.table.active') :
$t('employee_list.table.inactive') }}
</span>
<q-icon
:name="scope.row.last_work_day === null ? 'check' : 'clear'"
size="xs"
/>
</q-badge>
</div>
<span v-else>{{ scope.value }}</span>
</div>
</transition>
</q-td>
</template>
<!-- Template for custome failed-to-load state -->
<template #no-data="{ message, filter }">
<div class="full-width column items-center text-accent q-gutter-sm">
<span class="text-h6 q-mt-xl">
{{ message }}
</span>
<q-icon
size="4em"
:name="filter ? 'filter_alt_off' : 'error_outline'"
/>
</div>
</template>
</q-table>
</div>
</template>
<style scoped>
:deep(.q-table__card .q-table__sort-icon) {
fill: white !important;
color: white !important;
}
:deep(.q-table--dense .q-table__sort-icon) {
font-size: 150%;
}
.sticky-header-table thead tr:first-child th {
background-color: var(--q-primary);
margin-top: none;
}
thead tr th {
position: sticky;
z-index: 1;
}
thead tr:first-child th {
top: 0px;
}
&.q-table--loading thead tr:last-child th {
top: 48px;
}
tbody {
scroll-margin-top: 48px;
}
:deep(.q-table) {
transition: height 0.25s ease;
}
:deep(.q-table__grid-content) {
overflow: auto
}
</style>

View File

@ -1,16 +0,0 @@
<script setup lang="ts">
import { useEmployeeStore } from 'src/stores/employee-store';
const employeeStore = useEmployeeStore();
</script>
<template>
<q-dialog v-model="employeeStore.isShowingEmployeeAddModifyWindow">
<q-card>
<q-card-section>
LOL
</q-card-section>
<q-inner-loading :showing="employeeStore.isLoadingEmployeeProfile"/>
</q-card>
</q-dialog>
</template>

View File

@ -0,0 +1,75 @@
<script
setup
lang="ts"
>
import { computed, onMounted, ref } from 'vue';
import { useEmployeeStore } from 'src/stores/employee-store';
import { useEmployeeListApi } from 'src/modules/employee-list/composables/use-employee-api';
const employee_store = useEmployeeStore();
const employee_list_api = useEmployeeListApi();
const { presetId } = defineProps<{
presetId: number;
}>();
const employee_amount_using_preset = ref(0);
const delete_input_string = ref('');
const is_approve_deletion = computed(() => ['SUPPRIMER', 'DELETE'].includes(delete_input_string.value));
onMounted(() => {
const employees_with_preset = employee_store.employee_list.filter(employee => employee.preset_id === presetId);
employee_amount_using_preset.value = employees_with_preset.length;
})
</script>
<template>
<div
class="column flex-center bg-secondary q-pa-md rounded-10 shadow-24"
style="border: 2px solid var(--q-negative); width: 40vw !important;"
>
<span class="col-auto text-weight-bold text-uppercase text-center text-negative text-h5 q-pb-lg">
{{ $t('shared.label.remove') }}
</span>
<div
v-if="employee_amount_using_preset > 0"
class="col row flex-center text-weight-medium text-center q-mb-lg"
>
<span class="col-auto">{{ $t('employee_management.schedule_presets.delete_warning_employee_1') }}</span>
<span class="col-auto q-px-sm text-h6 text-weight-bolder text-negative">{{ employee_amount_using_preset
}}</span>
<span class="col-auto">{{ $t('employee_management.module_access.preset_employee') +
(employee_amount_using_preset > 1 ? 's' : '') }}</span>
<span>{{ $t('employee_management.schedule_presets.delete_warning_employee_2') }}</span>
</div>
<div class="col">
<span class="text-weight-bold text-uppercase">{{ $t('employee_management.schedule_presets.delete_warning')
}}</span>
<q-input
v-model="delete_input_string"
standout
dense
rounded
:placeholder="$t('employee_management.enter_delete_input')"
input-class="text-center"
:input-style="delete_input_string.length > 0 ? '' : 'opacity: 0.6;'"
class="q-my-sm"
/>
</div>
<div class="col-auto row">
<q-space />
<q-btn
push
dense
:disable="!is_approve_deletion"
:color="is_approve_deletion ? 'negative' : 'grey-6'"
:label="$t('shared.label.remove')"
class="q-px-md"
@click="employee_list_api.deleteSchedulePreset(presetId)"
/>
</div>
</div>
</template>

View File

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

View File

@ -0,0 +1,121 @@
<script
setup
lang="ts"
>
import SchedulePresetsDialogRow from './schedule-presets-dialog-row.vue';
import SchedulePresetsDialogDelete from 'src/modules/employee-list/components/schedule-presets-dialog-delete.vue';
import { useEmployeeListApi } from '../composables/use-employee-api';
import { SchedulePresetShift } from '../models/schedule-presets.models';
import { useSchedulePresetsStore } from 'src/stores/schedule-presets.store';
import { isShiftOverlap } from 'src/modules/timesheets/utils/shift.util';
const schedule_preset_store = useSchedulePresetsStore();
const employee_list_api = useEmployeeListApi();
</script>
<template>
<q-dialog
v-model="schedule_preset_store.is_manager_open"
full-width
>
<SchedulePresetsDialogDelete
v-if="schedule_preset_store.schedule_preset_dialog_mode === 'delete'"
:preset-id="schedule_preset_store.current_schedule_preset.id"
/>
<div
v-else
class="column flex-center bg-secondary rounded-10 shadow-24 no-wrap"
style="border: 2px solid var(--q-accent); width: 50vw !important;"
>
<div
class="row col-auto flex-center bg-primary full-width"
style="border-radius: 8px 8px 0 0;"
>
<span class="row col-auto text-uppercase text-weight-bold text-white q-py-sm">{{
schedule_preset_store.current_schedule_preset.id === -1 ?
$t('shared.label.add') :
$t('shared.label.modify') }}
</span>
</div>
<div class="row col-auto q-px-sm flex-center full-width q-py-sm">
<div class="col-8 bg-dark rounded-10 ellipsis">
<q-input
v-model="schedule_preset_store.current_schedule_preset.name"
standout
dense
hide-bottom-space
:placeholder="$t('employee_management.schedule_presets.preset_name_placeholder')"
class="text-uppercase"
input-class="text-weight-bold text-center"
>
<template #before>
<q-icon
name="edit"
color="accent"
class="q-ml-sm"
/>
</template>
</q-input>
</div>
</div>
<div
v-if="schedule_preset_store.schedule_preset_dialog_mode !== 'copy'"
class="column col full-width q-py-sm q-px-lg no-wrap scroll"
>
<div
v-for="weekday of schedule_preset_store.current_schedule_preset.weekdays"
:key="weekday.day"
class="row col-auto items-center q-my-xs shadow-2 bg-dark rounded-10 ellipsis"
style="min-height: 50px;"
>
<span class="col-2 text-uppercase text-weight-bold q-ml-sm ellipsis">{{
$t(`shared.weekday.${weekday.day.toLowerCase()}`) }}
</span>
<div class="col column">
<div
v-for="_shift, index in weekday.shifts"
:key="index"
>
<SchedulePresetsDialogRow
v-model:shift="weekday.shifts[index]!"
:error="weekday.is_error"
@click-delete="weekday.shifts.splice(index, 1)"
@blur-time-field="weekday.is_error = isShiftOverlap(weekday.shifts)"
/>
</div>
</div>
<div class="col-auto self-stretch">
<q-btn
square
icon="more_time"
color="accent"
class="full-height q-ma-none q-px-sm"
tabindex="-1"
@click="weekday.shifts.push(new SchedulePresetShift(weekday.day))"
/>
</div>
</div>
</div>
<div class="col-auto row self-end q-px-lg q-mt-sm full-width">
<q-space />
<q-btn
:disable="schedule_preset_store.current_schedule_preset.name === ''"
push
dense
:color="schedule_preset_store.current_schedule_preset.name === '' ? 'grey-7' : 'accent'"
icon="download"
:label="$t('shared.label.save')"
class="col-auto q-px-md q-mb-sm"
@click="employee_list_api.saveSchedulePreset"
/>
</div>
</div>
</q-dialog>
</template>

View File

@ -1,51 +0,0 @@
<script setup lang="ts">
import type { EmployeeListTableItem } from 'src/modules/employee-list/types/employee-list-table-interface';
// const getEmployeeAvatar = (first_name: string, last_name: string) => {
// // add logic here to see if user has an avatar image and return that instead of initials
// return first_name.charAt(0) + last_name.charAt(0);
// };
const { row } = defineProps<{
row: EmployeeListTableItem
}>()
const emit = defineEmits<{
onProfileClick: [email: string]
}>();
</script>
<template>
<q-card
v-ripple
class="column col-xs-6 col-sm-4 col-md-3 col-lg-2 no-wrap rounded-15 cursor-pointer q-ma-sm"
style="max-width: 230px;"
@click="emit('onProfileClick', row.email)"
>
<q-card-section class="col-6 text-center">
<q-avatar
color="primary"
size="8em"
class="shadow-3"
>
<img
src="src/assets/targo-default-avatar.png"
alt="employee avatar"
class="q-pa-xs"
>
</q-avatar>
</q-card-section>
<q-card-section
class="col-grow text-center text-h6 text-weight-medium text-uppercase q-pb-none"
style="line-height: 0.8em;"
>
<div class="ellipsis text-primary"> {{ row.first_name }} {{ row.last_name }} </div>
<q-separator color="primary" class="q-mx-sm q-mt-xs" />
<div class=" ellipsis-2-lines text-caption"> {{ row.job_title }} </div>
</q-card-section>
<q-card-section class="bg-primary text-white text-caption text-center q-py-none col-2 content-center ">
<div> {{ row.email }} </div>
</q-card-section>
</q-card>
</template>

View File

@ -1,145 +0,0 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useEmployeeListApi } from 'src/modules/employee-list/composables/use-employee-api';
import { useEmployeeStore } from 'src/stores/employee-store';
import { useI18n } from 'vue-i18n';
import SupervisorCrewTableItem from './supervisor-crew-table-item.vue';
import type { EmployeeListTableItem } from '../../types/employee-list-table-interface';
import type { QTableColumn } from 'quasar';
const employee_list_api = useEmployeeListApi();
const employee_store = useEmployeeStore();
const is_loading_list = ref<boolean>(true);
const { t } = useI18n();
const filter = ref("");
const is_grid_mode = ref(true);
const pagination = ref({ rowsPerPage: 0 });
const employee_list_columns = computed((): QTableColumn<EmployeeListTableItem>[] => [
{name: 'first_name', label: t('employee_list.table.first_name'), field: 'first_name', align: 'left'},
{name: 'last_name', label: t('employee_list.table.last_name'), field: 'last_name', align: 'left'},
{name: 'email', label: t('employee_list.table.email'), field: 'email', align: 'left'},
{name: 'supervisor_full_name', label: t('employee_list.table.supervisor'), field: 'supervisor_full_name', align: 'left'},
{name: 'company_name', label: t('employee_list.table.company'), field: 'company_name', align: 'left'},
{name: 'job_title', label: t('employee_list.table.role'), field: 'job_title', align: 'left'},
]);
onMounted( async () => {
is_loading_list.value = true;
await employee_list_api.getEmployeeList();
is_loading_list.value = false;
})
</script>
<template>
<div class="q-pa-lg col">
<q-table
dense
flat
hide-pagination
virtual-scroll
title=" "
card-style="max-height: 70vh;"
:rows="employee_store.employeeList"
:columns="employee_list_columns"
row-key="name"
v-model:pagination="pagination"
:rows-per-page-options="[0]"
:filter="filter"
class="q-pa-md bg-transparent"
:class="is_grid_mode ? '': 'my-sticky-header-table'"
:table-class="$q.dark.isActive ? 'q-px-md q-py-none q-mx-md rounded-10 bg-dark' : 'q-px-md q-py-none q-mx-md rounded-10 bg-white'"
color="primary"
table-header-class="text-primary text-uppercase"
card-container-class="justify-center"
:grid="is_grid_mode"
:loading="is_loading_list"
:no-data-label="$t('shared.error.no_data_found')"
:no-results-label="$t('shared.error.no_search_results')"
:loading-label="$t('shared.label.loading')"
@row-click="() => console.log('click!')"
>
<template v-slot:item="props">
<SupervisorCrewTableItem :row="props.row"/>
</template>
<template v-slot:top>
<div class="row full-width q-mb-sm">
<q-btn
push
color="primary"
icon="person_add"
:label="$t('shared.label.add')"
class="text-uppercase"
/>
<q-space />
<q-btn-toggle
v-model="is_grid_mode"
push
color="white"
text-color="primary"
toggle-color="primary"
class="q-mr-md"
:options="[
{icon: 'grid_view', value: true},
{icon: 'view_list', value: false},
]"
/>
<q-input
v-model="filter"
outlined
dense
rounded
color="primary"
bg-color="white"
label-color="primary"
:label="$t('shared.label.search')"
>
<template v-slot:append>
<q-icon
name="search"
color="primary"
/>
</template>
</q-input>
</div>
</template>
<!-- Template for custome failed-to-load state -->
<template v-slot:no-data="{ message, filter }">
<div class="full-width column items-center text-primary q-gutter-sm">
<span class="text-h6 q-mt-xl">
{{ message }}
</span>
<q-icon
size="4em"
:name="filter ? 'filter_alt_off' : 'error_outline'"
/>
</div>
</template>
</q-table>
</div>
</template>
<style lang="sass">
.my-sticky-header-table
thead tr:first-child th
background-color: var(--q-dark)
margin-top: none
thead tr th
position: sticky
z-index: 1
thead tr:first-child th
top: 0
&.q-table--loading thead tr:last-child th
top: 48px
tbody
scroll-margin-top: 48px
</style>

View File

@ -1,18 +1,86 @@
import { useEmployeeStore } from "src/stores/employee-store"; import { useEmployeeStore } from "src/stores/employee-store";
import { useSchedulePresetsStore } from "src/stores/schedule-presets.store";
import { SchedulePreset } from "../models/schedule-presets.models";
import { isShiftOverlap } from "src/modules/timesheets/utils/shift.util";
export const useEmployeeListApi = () => { export const useEmployeeListApi = () => {
const employeeListStore = useEmployeeStore(); const employee_store = useEmployeeStore();
const schedule_preset_store = useSchedulePresetsStore();
const getEmployeeList = (): Promise<void> => { const getEmployeeList = async (): Promise<void> => {
return employeeListStore.getEmployeeList(); employee_store.is_loading = true;
const success = await employee_store.getEmployeeList();
if (success) await schedule_preset_store.findSchedulePresetList();
employee_store.is_loading = false;
}; };
const getEmployeeDetails = (email: string): Promise<void> => { const getEmployeeDetails = async (email: string): Promise<void> => {
return employeeListStore.getEmployeeDetails(email); const success = await employee_store.getEmployeeDetails(email);
if (success && employee_store.employee.preset_id !== null) {
schedule_preset_store.setCurrentSchedulePreset(employee_store.employee.preset_id ?? -1);
}
}
const setSchedulePreset = (preset_id: number) => {
schedule_preset_store.setCurrentSchedulePreset(preset_id);
employee_store.employee.preset_id = preset_id < 0 ? null : preset_id;
}
const saveSchedulePreset = async () => {
// Get the currently edited schedule preset from the store (frontend model)
const preset = schedule_preset_store.current_schedule_preset;
// Check if there's any overlap between shifts. If there is, is_error property
// will be toggled to true and save process will stop
for (const weekday of preset.weekdays) {
weekday.is_error = isShiftOverlap(weekday.shifts);
}
if (preset.weekdays.some(weekday => weekday.is_error)) {
return;
}
// Flatten all weekday shifts into a single array
const preset_shifts = preset.weekdays.flatMap(weekday => weekday.shifts);
// Build a backend-compatible SchedulePreset instance
const backend_preset = new SchedulePreset(
preset.id,
preset.name,
preset_shifts
);
// Track whether the create/update operation succeeds
let success = false;
// Create a new preset if it has no backend ID, otherwise update the existing one
if (preset.id === -1)
success = await schedule_preset_store.createSchedulePreset(backend_preset);
else
success = await schedule_preset_store.updateSchedulePreset(backend_preset);
// On success, refresh the preset list and close the preset manager UI
if (success) {
await schedule_preset_store.findSchedulePresetList();
schedule_preset_store.is_manager_open = false;
}
}
const deleteSchedulePreset = async (preset_id: number) => {
const success = await schedule_preset_store.deleteSchedulePreset(preset_id);
if (success) {
await schedule_preset_store.findSchedulePresetList();
schedule_preset_store.is_manager_open = false;
}
} }
return { return {
getEmployeeList, getEmployeeList,
getEmployeeDetails, getEmployeeDetails,
setSchedulePreset,
saveSchedulePreset,
deleteSchedulePreset,
}; };
}; };

View File

@ -0,0 +1,17 @@
import type { EmbeddedValidationRule, EmbeddedValidationRuleFn } from "quasar";
export type QuasarRules = Record<EmbeddedValidationRule, EmbeddedValidationRuleFn>;
type EmployeeProfileValidationRule<T> = EmbeddedValidationRule | ((value: T, rules: QuasarRules, error_message: string) => boolean | string | Promise<boolean | string>);
export const useEmployeeProfileRules = () => {
const isNotEmpty: EmployeeProfileValidationRule<unknown> = (value, _rules, error_message) => (value !== undefined && value !== null && value !== '') || error_message;
return {
isNotEmpty,
}
}
export const company_options = [
{ label: 'Targo', value: 'Targo' },
{ label: 'Solucom', value: 'Solucom' },
]

View File

@ -0,0 +1,149 @@
import type { QSelectOption, QTableColumn } from "quasar";
import type { UserModuleAccess } from "src/modules/shared/models/user.models";
export type ModuleAccessPreset = 'admin' | 'supervisor' | 'employee' | 'none';
export type CompanyNames = 'Targo' | 'Solucom';
export interface PaidTimeOff {
sick_hours: number;
vacation_hours: number;
banked_hours: number;
last_updated?: string | null;
}
export class EmployeeProfile {
first_name: string;
last_name: string;
supervisor_full_name: string;
company_name: CompanyNames;
job_title: string;
email: string;
phone_number: string;
first_work_day: string;
last_work_day?: string | null;
external_payroll_id?: number;
daily_expected_hours?: number;
paid_time_off?: PaidTimeOff;
residence: string;
birth_date: string;
is_supervisor: boolean;
user_module_access: UserModuleAccess[];
preset_id?: number | null;
constructor() {
this.first_name = '';
this.last_name = '';
this.supervisor_full_name = '';
this.company_name = 'Targo';
this.job_title = '';
this.email = '';
this.phone_number = '';
this.first_work_day = '';
this.last_work_day = null;
this.residence = '';
this.birth_date = '';
this.is_supervisor = false;
this.user_module_access = ['dashboard',];
}
}
export interface EmployeeListFilters {
search_bar_string: string;
hide_inactive_users: boolean;
};
export const employee_list_columns: QTableColumn<EmployeeProfile>[] = [
{
name: 'first_name',
label: 'timesheet_approvals.table.full_name',
field: 'first_name',
align: 'left',
sortable: true,
},
{
name: 'last_name',
label: 'employee_list.table.last_name',
field: 'last_name',
align: 'left',
sortable: true,
},
{
name: 'email',
label: 'employee_list.table.email',
field: 'email',
align: 'left',
sortable: true,
},
{
name: 'supervisor_full_name',
label: 'employee_list.table.supervisor',
field: 'supervisor_full_name',
align: 'left',
sortable: true,
},
{
name: 'company_name',
label: 'employee_list.table.company',
field: 'company_name',
align: 'left',
sortable: true,
},
{
name: 'phone_number',
label: 'employee_list.table.phone_number',
field: 'phone_number',
align: 'left',
sortable: true,
},
{
name: 'job_title',
label: 'employee_list.table.role',
field: 'job_title',
align: 'left',
},
{
name: 'expected_daily_hours',
label: 'employee_list.table.expected_daily_hours',
field: 'daily_expected_hours',
align: 'left',
},
{
name: 'last_work_day',
label: 'status',
field: 'last_work_day',
align: 'center',
sortable: true,
sort: (a: string | null, b: string | null) => {
if (a === null && b === null) return 0;
else if (a === null && b !== null) return 1;
else return -1;
},
},
];
export const employee_access_options: QSelectOption<UserModuleAccess>[] = [
{ label: 'dashboard', value: 'dashboard' },
{ label: 'employee_list', value: 'employee_list' },
{ label: 'personal_profile', value: 'personal_profile' },
{ label: 'timesheets', value: 'timesheets' },
{ label: 'employee_management', value: 'employee_management' },
{ label: 'timesheets_approval', value: 'timesheets_approval' },
]
export const employee_access_presets: Record<ModuleAccessPreset, UserModuleAccess[]> = {
'admin' : ['dashboard', 'employee_list', 'employee_management', 'personal_profile', 'timesheets', 'timesheets_approval'],
'supervisor' : ['dashboard', 'employee_list', 'personal_profile', 'timesheets', 'timesheets_approval'],
'employee' : ['dashboard', 'timesheets', 'personal_profile', 'employee_list'],
'none' : [],
}
export const getEmployeeAccessOptionIcon = (module: UserModuleAccess): string => {
switch (module) {
case 'dashboard': return 'home';
case 'employee_list' : return 'groups';
case 'employee_management': return 'las la-user-edit';
case 'personal_profile': return 'las la-id-card';
case 'timesheets': return 'punch_clock';
case 'timesheets_approval': return 'event_available';
}
}

View File

@ -0,0 +1,59 @@
import type { ShiftType } from "src/modules/timesheets/models/shift.models";
export type Weekday = 'SUN' | 'MON' | 'TUE' | 'WED' | 'THU' | 'FRI' | 'SAT';
export const WEEKDAYS: Weekday[] = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'];
export type PresetManagerMode = 'create' | 'update' | 'copy' | 'delete';
export class SchedulePreset {
id: number;
name: string;
shifts: SchedulePresetShift[];
constructor(id?: number, name?: string, shifts?: SchedulePresetShift[]) {
this.id = id ?? -1;
this.name = name ?? 'default';
this.shifts = shifts ?? [];
}
}
export class SchedulePresetShift {
preset_id: number;
week_day: Weekday;
type: ShiftType;
start_time: string;
end_time: string;
is_remote: boolean;
constructor(weekday: Weekday) {
this.preset_id = -1;
this.week_day = weekday;
this.type = 'REGULAR';
this.start_time = '00:00';
this.end_time = '00:00';
this.is_remote = false;
}
}
export class SchedulePresetFrontend {
id: number;
name: string;
weekdays: WeekdayPresetShifts[];
constructor(schedule_preset?: SchedulePreset) {
this.id = schedule_preset?.id ?? -1;
this.name = schedule_preset?.name ?? '';
this.weekdays = WEEKDAYS.map(day => ({
day,
is_error: false,
shifts: schedule_preset !== undefined ? schedule_preset?.shifts.filter(shift => shift.week_day === day) : [],
}))
}
}
export interface WeekdayPresetShifts {
day: Weekday;
is_error: boolean;
shifts: SchedulePresetShift[];
}

View File

@ -0,0 +1,31 @@
import { api } from 'src/boot/axios';
import type { EmployeeProfile } from 'src/modules/employee-list/models/employee-profile.models';
import type { BackendResponse } from 'src/modules/shared/models/backend-response.models';
export const EmployeeListService = {
getEmployeeList: async (): Promise<BackendResponse<EmployeeProfile[]>> => {
const response = await api.get<BackendResponse<EmployeeProfile[]>>('/employees/employee-list')
return response.data;
},
getEmployeeDetails: async (): Promise<BackendResponse<EmployeeProfile>> => {
const response = await api.get('employees/profile');
return response.data;
},
getEmployeeDetailsWithEmployeeEmail: async (employee_email: string): Promise<BackendResponse<EmployeeProfile>> => {
const response = await api.get(`employees/profile?employee_email=${employee_email}`);
return response.data;
},
createNewEmployee: async (profile: Omit<EmployeeProfile, 'last_work_day' | 'birth_date'>): Promise<BackendResponse<EmployeeProfile>> => {
const response = await api.post('employees/create', profile);
return response.data;
},
updateEmployee: async (profile: EmployeeProfile): Promise<BackendResponse<EmployeeProfile>> => {
const response = await api.patch('employees/update', profile);
return response.data;
},
};

View File

@ -0,0 +1,30 @@
import { api } from "src/boot/axios";
import type { SchedulePreset } from "src/modules/employee-list/models/schedule-presets.models";
import type { BackendResponse } from "src/modules/shared/models/backend-response.models";
export const SchedulePresetsService = {
createSchedulePresets: async (preset: SchedulePreset) => {
const response = await api.post(`/schedule-presets/create/`, preset);
return response.data;
},
updateSchedulePresets: async (preset: SchedulePreset): Promise<BackendResponse<boolean>> => {
const response = await api.patch(`/schedule-presets/update`, preset);
return response.data;
},
deleteSchedulePresets: async (preset_id: number): Promise<BackendResponse<boolean>> => {
const response = await api.delete(`/schedule-presets/delete/${preset_id}`);
return response.data;
},
getSchedulePresetsList: async (): Promise<BackendResponse<SchedulePreset[]>> => {
const response = await api.get(`/schedule-presets/find-list`);
return response.data;
},
applyPresets: async (preset_name: string, start_date: string) => {
const response = await api.post(`/schedule-presets/apply-presets/`, { preset: preset_name, start: start_date });
return response.data;
},
};

View File

@ -1,17 +0,0 @@
// /* eslint-disable */
import { api } from 'src/boot/axios';
import type { EmployeeListTableItem } from '../types/employee-list-table-interface';
import type { EmployeeProfile } from '../types/employee-profile-interface';
export const EmployeeListService = {
getEmployeeList: async (): Promise<EmployeeListTableItem[]> => {
const response = await api.get<EmployeeListTableItem[]>('/employees/employee-list')
return response.data;
},
getEmployeeDetails: async (email: string): Promise<EmployeeProfile> => {
const response = await api.get<EmployeeProfile>('employees/profile/' + email);
return response.data;
},
};

Some files were not shown because too many files have changed in this diff Show More