feat(timesheet): added comment popup

This commit is contained in:
Matthieu Haineault 2025-09-10 15:26:32 -04:00
parent 5c0c9036c4
commit a2f07ac3fb
10 changed files with 145 additions and 419 deletions

View File

@ -2,6 +2,7 @@ import { useTimesheetStore } from "src/stores/timesheet-store";
import { useAuthStore } from "src/stores/auth-store";
import type { PayPeriodReportFilters } from "../types/timesheet-approval-pay-period-report-interface";
import { default_pay_period_overview_employee, type PayPeriodOverviewEmployee } from "../types/timesheet-approval-pay-period-overview-employee-interface";
import { date } from "quasar";
export const useTimesheetApprovalApi = () => {
const timesheet_store = useTimesheetStore();
@ -63,11 +64,29 @@ export const useTimesheetApprovalApi = () => {
await timesheet_store.getTimesheetApprovalCSVReport(options);
};
const getCurrentPayPerdioOverview = async (): Promise<void> => {
const today = date.formatDate(new Date(), 'YYYY-MM-DD');
const success = await timesheet_store.getPayPeriodByDate(today);
if(!success) return;
const { pay_year, pay_period_no } = timesheet_store.current_pay_period;
await timesheet_store.getTimesheetApprovalPayPeriodEmployeeOverviews(
pay_year,
pay_period_no,
auth_store.user.email
);
await timesheet_store.getTimesheetsByPayPeriodAndEmail(auth_store.user.email);
};
return {
getPayPeriodOverviewByDate,
getNextPayPeriodOverview,
getPayPeriodOverviewByEmployeeEmail,
getTimesheetsByPayPeriodAndEmail,
getTimesheetApprovalCSVReport
getTimesheetApprovalCSVReport,
getCurrentPayPerdioOverview
}
};

View File

@ -1,266 +0,0 @@
<script setup lang="ts">
/* eslint-disable */
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useTimesheetStore } from 'src/stores/timesheet-store';
import TimesheetShiftComment from '../shift/timesheet-shift-comment.vue';
import TimesheetSavePayload from './timesheet-save-payload.vue';
import type { Shift } from '../../types/timesheet-shift-interface';
import type{ CreateShiftPayload } from '../../types/timesheet-shifts-payload-interface';
const timesheet_store = useTimesheetStore();
const { t, tm, locale } = useI18n();
const SHIFT_KEY = ['REGULAR', 'EVENING', 'EMERGENCY', 'HOLIDAY', 'VACATION', 'SICK'] as const;
const days = computed(()=> {
void locale.value;
return (tm('timesheet.days') as string[]) ?? [];
});
const shift_options = computed(()=> {
void locale.value;
return SHIFT_KEY.map(key => ({ value: key, label: t(`timesheet.shift_types.${key}`)}))
});
const empty_row = { date:'', type: '', start_time: '', end_time: '', comment: '', is_approved: false, is_remote: false };
//Week dates
const week_dates = computed(() => {
const start_date = timesheet_store.current_timesheet.start_day;
if(!start_date) return [];
const mm = /^(\d{4})-(\d{2})-(\d{2})$/.exec(start_date);
if(!mm) return [];
const year = Number(mm[1]), month = Number(mm[2]), day = Number(mm[3])
const base = new Date(Date.UTC(year, month - 1, day));
const yyyymmdd = (date: Date) => {
const yyyy = date.getFullYear();
const mm = String(date.getUTCMonth() + 1).padStart(2,'0');
const dd = String(date.getUTCDate()).padStart(2,'0');
return `${yyyy}-${mm}-${dd}`};
return Array.from({length:7 }, (_, i) => {
const date = new Date(base);
date.setUTCDate(base.getDate() + i);
return yyyymmdd(date);
});
});
//filling timesheet with shifts
const rows = ref<Shift[]>(
days.value.map((_,index) => {
const date_ISO = week_dates.value[index];
const shift = timesheet_store.current_timesheet.shifts.find(sh => sh.date === date_ISO);
return shift ? {
date:shift.date || '',
type: shift.bank_type || '',
start_time: shift.start_time || '',
end_time: shift.end_time || '',
comment: shift.description || '',
is_approved: !!shift.is_approved,
is_remote: !!shift.is_remote,
}
: { ...empty_row };
})
);
const hasData = (row: Shift) => !!(row.type || row.start_time || row.end_time || row.comment);
const show_comment = ref(false);
const selected_index = ref<number | null>(null);
const selected_row = computed<Shift | undefined>(()=>
selected_index.value != null ? rows.value[selected_index.value] : undefined
);
const setComment = (comment: string) => {
if(selected_row.value) selected_row.value.comment = comment;
show_comment.value = false;
}
const onClickComment = (index: number)=> {
selected_index.value = index;
show_comment.value = true;
};
const clearRow = (index: number) => {
rows.value[index] = { ...empty_row };
}
const emit = defineEmits<{ (e: 'save', payload: CreateShiftPayload[]): void }>();
//onMounted?
watch(
() => [timesheet_store.current_timesheet.start_day, timesheet_store.current_timesheet.shifts],
() => {
const dates = week_dates.value;
rows.value = days.value.map((_, idx)=> {
const shift = timesheet_store.current_timesheet.shifts.find(sh => sh.date === dates[idx]);
return shift
? { date: shift.date || '',
type: shift.bank_type || '',
start_time: shift.start_time || '',
end_time: shift.end_time || '',
comment: shift.description || '',
is_approved: shift.is_approved,
is_remote: shift.is_remote,
}
: { date: '',
type: '',
start_time: '',
end_time: '',
comment: '',
is_approved: false,
is_remote: false
};
});
},
{ deep: true, immediate: true }
);
</script>
<template>
<q-card class="bg-transparent q-pa-md q-ma-md">
<q-dialog
v-model="show_comment"
transition-show="fade"
transition-hide="fade"
persistent
>
<!-- comment popup -->
<TimesheetShiftComment
:comment-string="selected_row?.comment ?? ''"
@click-save="setComment"
@click-close="show_comment = false"
/>
</q-dialog>
<q-form
autofocus
class="bg-white q-pa-sm q-pt-lg rounded-10">
<div
v-for="(row, index) in rows"
:key="week_dates[index] ?? index"
class="q-gutter-sm q-mb-sm"
:class="$q.screen.lt.md ? 'column' : 'row'" >
<!--Week days-->
<span class="text-weight-bold text-primary col-1">{{ days[index] }}</span>
<!-- remote work toggle -->
<q-toggle
v-model="row.is_remote"
:disable="row.is_approved"
color="primary"
checked-icon="home_work"
unchecked-icon="business"
icon="home_work"
class="col-auto q-ml-sm"
>
<q-tooltip
anchor="top middle"
self="center middle"
class="bg-primary text-uppercase text-weight-bold"
>{{ $t('timesheet.remote_button') }}
</q-tooltip>
</q-toggle>
<!-- type selection -->
<q-select
v-model="row.type"
:options="shift_options"
:readonly="row.is_approved"
class="col-3"
:label="$t('timesheet.shift_types_label')"
dense
filled
color="primary"
standout="bg-primary text-white"
options-dense
emit-value
map-options
option-value="value"
option-label="label"
hide-dropdown-icon
/>
<!-- start time input -->
<q-input
v-model="row.start_time"
:readonly="row.is_approved"
class="col-auto"
:label="$t('timesheet.fields.start')"
dense
filled
color="primary"
type="time"
step="300"
standout="bg-primary text-white"
/>
<!-- end time input -->
<q-input
v-model="row.end_time"
:readonly="row.is_approved"
class="col-auto"
:label="$t('timesheet.fields.end')"
dense
filled
color="primary"
type="time"
step="300"
standout="bg-primary text-white"
/>
<div class="col-3">
<!-- comment button -->
<q-btn
:icon="row.comment.length > 0 ? 'announcement':'chat_bubble_outline'"
:color="row.comment.length > 0 ? 'primary' : 'grey-8'"
:disable="row.is_approved"
flat
class="col-auto"
@click="onClickComment(index)"
/>
<!-- expense button -->
<q-btn
:icon="row.comment.length > 0 ? 'receipt_long':'attach_money'"
flat
dense
class="q-pa-none q-ma-sm col-1"
:color="hasData(row) ? 'primary' : 'grey-8'"
@click="clearRow(index)"
/>
</div>
<!-- reset entries button -->
<q-btn
icon="cleaning_services"
flat
dense
class="q-pa-none q-ma-sm col-auto"
:color="hasData(row) ? 'primary' : 'grey-4'"
@click="clearRow(index)"
/>
<!-- add one more shift buttons -->
<q-btn
icon="more_time"
flat
dense
class="q-pa-none q-ma-sm col-auto"
color="primary"
>
<q-tooltip
anchor="top middle"
self="center middle"
class="bg-primary text-uppercase text-weight-bold"
>{{ $t('timesheet.add_shift') }}
</q-tooltip>
</q-btn>
</div>
<TimesheetSavePayload
:week_dates="week_dates"
:rows="rows"
@save="(payload) => emit('save', payload)"
/>
</q-form>
</q-card>
</template>

View File

@ -1,10 +1,36 @@
<script setup lang="ts">
import type { Shift } from 'src/modules/timesheets/types/timesheet-shift-interface';
import { computed, ref } from 'vue';
import ShiftComment from '../shift/shift-comment.vue';
/* eslint-disable */
const props = defineProps<{
shift: Shift;
}>();
const emit = defineEmits<{
'save-comment': [payload: { comment: string; shift_id?: string | number; date?: string }];
}>();
const getComment = computed(()=> (props.shift as any).description || (props.shift as any).comment || '');
const show_comment = ref(false);
const openComment = ()=> { show_comment.value = true; };
const closeComment = ()=> { show_comment.value = false; };
const handleSave = (comment: string)=> {
emit('save-comment', {
comment,
shift_id: (props.shift as any).id,
date:(props.shift as any).date
});
show_comment.value = false;
}
const hasComment = computed(()=> {
const comment = (props.shift as any).description ?? (props.shift as any).comment ?? '';
return typeof comment === 'string' && comment.trim().length > 0;
})
const comment_icon = computed(()=> (hasComment.value ? 'announcement' : 'chat_bubble_outline'));
const comment_color = computed(()=> (hasComment.value ? 'primary' : 'grey-8'));
const getShiftColor = (type: string): string => {
switch(type) {
case 'REGULAR': return 'secondary';
@ -85,11 +111,11 @@
v-if="props.shift.type !== ''"
flat
dense
color='grey-8'
icon="chat_bubble_outline"
:color="comment_color"
:icon="comment_icon"
class="q-pa-none"
@click="openComment"
/>
<!-- insert_drive_file or request_quote -->
<q-btn
v-if="props.shift.type !== ''"
@ -101,4 +127,17 @@
/>
</q-card-section>
</q-card-section>
<q-dialog
v-model="show_comment"
transition-show="fade"
transition-hide="fade"
persistent
>
<ShiftComment
:comment-string="getComment"
@click-close="closeComment"
@click-save="handleSave"
/>
</q-dialog>
</template>

View File

@ -3,7 +3,7 @@ import { default_shift, type Shift } from 'src/modules/timesheets/types/timeshee
import TimesheetEmployeeDetailsShiftsRowHeader from './timesheet-details-shifts-row-header.vue';
import TimesheetEmployeeDetailsShiftsRow from './timesheet-details-shifts-row.vue';
import type { PayPeriod } from 'src/modules/shared/types/pay-period-interface';
import type { TimesheetPayPeriodDetailsOverview } from '../types/timesheet-pay-period-details-overview-interface';
import type { TimesheetPayPeriodDetailsOverview } from '../../types/timesheet-pay-period-details-overview-interface';
const props = defineProps<{
rawData: TimesheetPayPeriodDetailsOverview;

View File

@ -1,78 +1,28 @@
<script setup lang="ts">
/* eslint-disable */
import { computed, ref, watch } from 'vue';
import { date as qdate } from 'quasar';
import { ref } from 'vue';
import { date} from 'quasar';
import type { QDateDetails } from 'src/modules/shared/types/q-date-details';
const is_showing_calendar_picker = ref(false);
const calendar_date = ref(date.formatDate( Date.now(), 'YYYY-MM-DD' ));
const props = defineProps<{
isDisabled?: boolean;
minPickableDate?: string;
currentLabel?: string;
isPreviousLimit:boolean;
}>();
const emit = defineEmits<{
(e: 'navigate-to', targetIso: string, meta?: { reason: 'previous' | 'next' | 'current' | 'picked' }): void;
(e: 'date-selected', value: string, reason?: string, details?: QDateDetails): void;
(e: 'pressed-previous-button'): void;
(e: 'pressed-next-button'): void;
(e: 'pressed-current-button'): void;
'date-selected': [value: string, reason?: string, details?: QDateDetails]
'pressed-previous-button': []
'pressed-next-button': []
}>();
const is_showing_calendar_picker = ref(false);
const currentStartEndDate = computed(()=> {
const label = props.currentLabel ?? '';
const parts = label.split('.');
if(parts.length < 2) return null;
return { start_iso: parts[0]!, end_iso: parts[1]! };
});
const to_date = (iso?: string) => (iso ? qdate.extractDate(iso, 'YYYY-MM-DD'): null);
const to_iso = (date: Date) => qdate.formatDate(date, 'YYYY-MM-DD');
const calendar_date = ref(currentStartEndDate.value?.start_iso ?? to_iso(new Date()));
watch(() => props.currentLabel, () => {
if(currentStartEndDate.value?.start_iso) {
calendar_date.value = currentStartEndDate.value.start_iso;
}
});
const is_previous_limit = computed(() => {
const start_end = currentStartEndDate.value;
if (!start_end || !props.minPickableDate) return false;
const prev_anchor = qdate.addToDate(to_date(start_end.start_iso)!, { days: -1 });
return to_iso(prev_anchor) <= props.minPickableDate;
});
const onDateSelected = (value: string, reason: string, details: QDateDetails) => {
calendar_date.value = value;
is_showing_calendar_picker.value = false;
emit('date-selected', value, reason, details);
emit('navigate-to', value, { reason: 'picked' });
};
const goPrevious = () => {
emit('pressed-previous-button');
const start_end = currentStartEndDate.value;
if (!start_end) return;
const prev_anchor = qdate.addToDate(to_date(start_end.start_iso)!, { days: -1 });
emit('navigate-to', to_iso(prev_anchor), { reason: 'previous' });
};
const goCurrent = () => {
emit('pressed-current-button');
emit('navigate-to', to_iso(new Date()), { reason: 'current' });
};
const goNext = () => {
emit('pressed-next-button');
const start_end = currentStartEndDate.value;
if (!start_end) return;
const next_anchor = qdate.addToDate(to_date(start_end.end_iso)!, { days: 1 });
emit('navigate-to', to_iso(next_anchor), { reason: 'next' });
};
</script>
<template>
@ -82,8 +32,8 @@
push rounded
icon="keyboard_arrow_left"
color="primary"
@click="goPrevious"
:disable="is_previous_limit || props.isDisabled"
@click="emit('pressed-previous-button')"
:disable="props.isPreviousLimit || props.isDisabled"
class="q-mr-sm q-px-sm"
>
<q-tooltip
@ -93,29 +43,13 @@
> {{ $t( 'timesheet.nav_button.previous_week' )}}
</q-tooltip>
</q-btn>
<!-- navigation to current week -->
<q-btn
push rounded
icon="today"
color="primary"
@click="goCurrent"
:disable="!!props.isDisabled"
class="q-mr-sm q-px-lg"
>
<q-tooltip
anchor="top middle"
self="center middle"
class="bg-primary text-uppercase text-weight-bold"
>{{ $t('timesheet.nav_button.current_week') }}
</q-tooltip>
</q-btn>
<!-- navigation through calendar date picker -->
<q-btn
push rounded
icon="calendar_month"
color="primary"
@click="is_showing_calendar_picker = true"
:disable="!!props.isDisabled"
:disable="props.isDisabled"
class="q-px-lg"
>
<q-tooltip
@ -130,8 +64,8 @@
push rounded
icon="keyboard_arrow_right"
color="primary"
@click="goNext"
:disable="!!props.isDisabled"
@click="emit('pressed-next-button')"
:disable="props.isDisabled"
class="q-ml-sm q-px-sm"
>
<q-tooltip
@ -155,7 +89,7 @@
class="q-mt-xl"
today-btn
mask="YYYY-MM-DD"
:options="date => !props.minPickableDate || date > props.minPickableDate"
:options="date => date > '2023/12/16'"
@update:model-value="onDateSelected"
/>
</q-dialog>

View File

@ -1,60 +1,49 @@
import { useAuthStore } from "src/stores/auth-store";
import { useTimesheetStore } from "src/stores/timesheet-store"
import { ref } from "vue";
import { timesheetTempService } from "../services/timesheet-services";
import type { CreateShiftPayload } from "../types/timesheet-shifts-payload-interface";
export const useTimesheetApi = () => {
const timesheet_store = useTimesheetStore();
const auth_store = useAuthStore();
const week_offset = ref(0);
const fetchWeek = async (offset = week_offset.value) => {
const email = auth_store.user?.email;
if(!email) return;
try{
timesheet_store.is_loading = true;
const timesheet = await timesheetTempService.getTimesheetsByEmail(email, offset);
timesheet_store.current_timesheet = timesheet;
week_offset.value = offset;
}catch (err) {
console.error('fetch week error', err);
timesheet_store.current_timesheet = { ...timesheet_store.current_timesheet, shifts: [], expenses: [] };
} finally {
timesheet_store.is_loading = false;
const getTimesheetsByDate = async (date_string: string) => {
const success = await timesheet_store.getPayPeriodByDate(date_string);
if (success) {
await timesheet_store.getTimesheetsByPayPeriodAndEmail(auth_store.user.email)
}
}
const fetchPayPeriod = async (direction: number) => {
const current_pay_period = timesheet_store.current_pay_period;
let new_pay_period_no = current_pay_period.pay_period_no + direction;
let new_pay_year = current_pay_period.pay_year;
if (new_pay_period_no > 26) {
new_pay_period_no = 1;
new_pay_year += 1;
}
if (new_pay_period_no < 1) {
new_pay_period_no = 26;
new_pay_year -= 1;
}
const success = await timesheet_store.getPayPeriodByYearAndPeriodNumber(new_pay_year, new_pay_period_no);
if (success) {
await timesheet_store.getTimesheetsByPayPeriodAndEmail(auth_store.user.email);
}
};
const this_week = async () => fetchWeek(0);
const next_week = async () => fetchWeek(week_offset.value + 1);
const previous_week = async () => fetchWeek(week_offset.value - 1);
const saveTimesheetShifts = async (shifts: CreateShiftPayload[]) => {
const email = auth_store.user?.email;
if(!email || shifts.length === 0) return;
await timesheet_store.createTimesheetShifts(email, shifts, week_offset.value);
};
const weekStart = (date: Date) => {
const x = new Date(date);
x.setHours(0, 0, 0, 0);
x.setDate(x.getDate() - x.getDay());
return x;
};
const getCurrentWeekTimesheetOverview = async (when: Date = new Date()) => {
const off = Math.trunc((weekStart(when).getTime() - weekStart(new Date()).getTime()) / 604800000);
await fetchWeek(off);
}
const getCurrentPayPeriod = async () => fetchPayPeriod(0);
const getNextPayPeriod = async () => fetchPayPeriod(1);
const getPreviousPayPeriod = async () => fetchPayPeriod(-1);
return {
week_offset,
fetchWeek,
this_week,
next_week,
previous_week,
saveTimesheetShifts,
getCurrentWeekTimesheetOverview,
getTimesheetsByDate,
fetchPayPeriod,
getCurrentPayPeriod,
getNextPayPeriod,
getPreviousPayPeriod,
};
};

View File

@ -1,17 +1,19 @@
<script setup lang="ts">
import { useTimesheetStore } from 'src/stores/timesheet-store';
import { useAuthStore } from 'src/stores/auth-store';
import { useTimesheetApi } from '../composables/use-timesheet-api';
import { computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { date } from 'quasar';
import TimesheetEmployeeDetailsShifts from '../components/timesheet-details-shifts.vue';
import { useAuthStore } from 'src/stores/auth-store';
import TimesheetEmployeeDetailsShifts from '../components/timesheet/timesheet-details-shifts.vue';
import TimesheetNavigation from '../components/timesheet/timesheet-navigation.vue';
import ShiftsLegend from '../components/shift/shifts-legend.vue';
/* eslint-disable */
const { locale } = useI18n();
const timesheet_store = useTimesheetStore();
const auth_store = useAuthStore();
const timesheet_api = useTimesheetApi();
const date_options: Intl.DateTimeFormatOptions = {
day: 'numeric',
@ -30,14 +32,22 @@ const pay_period_label = computed(() => {
return { start_date, end_date };
});
const is_calendar_limit = computed( () => {
return timesheet_store.current_pay_period.pay_year === 2024 &&
timesheet_store.current_pay_period.pay_period_no <= 1;
});
const onDateSelected = async (date_string: string) => {
await timesheet_api.getTimesheetsByDate(date_string);
};
const loadByDate = async (isoDate: string) => {
await timesheet_store.getPayPeriodByDate(isoDate);
await timesheet_store.getTimesheetsByPayPeriodAndEmail(auth_store.user.email);
};
onMounted( async () => {
await timesheet_store.getPayPeriodByDate(date.formatDate(new Date(), 'YYYY-MM-DD'));
await timesheet_store.getTimesheetsByPayPeriodAndEmail(auth_store.user.email);
await loadByDate(date.formatDate(new Date(), 'YYYY-MM-DD' ));
});
@ -73,9 +83,10 @@ onMounted( async () => {
<q-card flat class="q-mt-md bg-secondary">
<TimesheetNavigation
:is-disabled="timesheet_store.is_loading"
:min-pickable-date="'2023-12-16'"
:current-label="timesheet_store.current_pay_period?.label || ''"
@navigate-to="(iso) => loadByDate(iso)"
:is-previous-limit="is_calendar_limit"
@date-selected="value => onDateSelected(value)"
@pressed-previous-button="timesheet_api.getPreviousPayPeriod()"
@pressed-next-button="timesheet_api.getNextPayPeriod()"
/>
<ShiftsLegend
:is-loading="false"
@ -85,8 +96,8 @@ onMounted( async () => {
:raw-data="timesheet_store.pay_period_employee_details"
:current-pay-period="timesheet_store.current_pay_period"
/>
</q-card-section>
<q-inner-loading :showing="timesheet_store.is_loading" color="primary"/>
</q-card-section>
</q-card>
</div>
</q-page>