Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 77 additions & 10 deletions assets/vue/components/attendance/AttendanceCalendarForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
<BaseInputNumber
v-model="formData.repeatDays"
:label="t('Number of days')"
min="1"
:min="1"
id="xdays_number"
/>
</div>
Expand All @@ -47,11 +47,12 @@
/>
</div>

<!-- Duration in minutes -->
<BaseInputNumber
id="end_date_time"
id="duration_minutes"
v-model="formData.duration"
:label="t('Duration (minutes)')"
min="1"
:min="1"
/>

<!-- Group -->
Expand Down Expand Up @@ -82,13 +83,13 @@
<script setup>
import { onMounted, reactive, ref } from "vue"
import { useI18n } from "vue-i18n"
import { useRoute } from "vue-router"
import attendanceService from "../../services/attendanceService"
import BaseCalendar from "../../components/basecomponents/BaseCalendar.vue"
import BaseCheckbox from "../../components/basecomponents/BaseCheckbox.vue"
import BaseSelect from "../../components/basecomponents/BaseSelect.vue"
import LayoutFormButtons from "../../components/layout/LayoutFormButtons.vue"
import BaseButton from "../../components/basecomponents/BaseButton.vue"
import { useRoute } from "vue-router"
import BaseInputNumber from "../basecomponents/BaseInputNumber.vue"

const { t } = useI18n()
Expand All @@ -103,7 +104,7 @@ const formData = reactive({
repeatEndDate: "",
repeatDays: 0,
group: "",
duration: null,
duration: 60,
})

const repeatTypeOptions = [
Expand All @@ -116,6 +117,58 @@ const repeatTypeOptions = [

const groupOptions = ref([])

/**
* Normalize a value coming from BaseCalendar into a local date-time string
* without timezone information.
*
* Goal:
* - Treat picked time as local time.
* - Avoid sending UTC with Z or offsets to the backend.
*/
const normalizeToLocalDateTime = (value) => {
if (!value) {
return null
}

// Case 1: Date instance -> build local "YYYY-MM-DDTHH:mm:ss"
if (value instanceof Date) {
const year = value.getFullYear()
const month = String(value.getMonth() + 1).padStart(2, "0")
const day = String(value.getDate()).padStart(2, "0")
const hours = String(value.getHours()).padStart(2, "0")
const minutes = String(value.getMinutes()).padStart(2, "0")
const seconds = String(value.getSeconds()).padStart(2, "0")

return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}`
}

// Case 2: string -> strip timezone/offset, keep local time
if (typeof value === "string") {
// Examples:
// - "2025-11-20T13:00"
// - "2025-11-20T13:00:00"
// - "2025-11-20T13:00:00.000Z"
// - "2025-11-20T13:00:00+01:00"
const match = value.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2})?)/)

if (match) {
const base = match[1]
// If we only have "YYYY-MM-DDTHH:mm", add ":00" for seconds
if (base.length === 16) {
return `${base}:00`
}

return base
}

// Fallback: if unknown format, send as-is
return value
}

// Fallback for unsupported types
return null
}

const toggleRepeatOptions = () => {
if (!formData.repeatDate) {
formData.repeatType = ""
Expand All @@ -124,22 +177,36 @@ const toggleRepeatOptions = () => {
}
}

const submitForm = async () => {
const submitForm = async (event) => {
// Prevent default form submit behavior
if (event && typeof event.preventDefault === "function") {
event.preventDefault()
}

// Basic required fields checks
if (!formData.startDate) {
console.error("[Attendance] Start date is required.")
return
}

if (formData.repeatDate && (!formData.repeatType || !formData.repeatEndDate)) {
console.error("[Attendance] Repeat settings are incomplete.")
return
}

// Normalize date/time values as local strings without timezone
const normalizedStartDate = normalizeToLocalDateTime(formData.startDate)
const normalizedRepeatEndDate = formData.repeatDate
? normalizeToLocalDateTime(formData.repeatEndDate)
: null

const payload = {
startDate: formData.startDate,
startDate: normalizedStartDate,
repeatDate: formData.repeatDate,
repeatType: formData.repeatType,
repeatEndDate: formData.repeatEndDate,
repeatType: formData.repeatType || null,
repeatEndDate: normalizedRepeatEndDate,
repeatDays: formData.repeatType === "every-x-days" ? formData.repeatDays : null,
group: formData.group ? parseInt(formData.group) : null,
group: formData.group ? parseInt(formData.group, 10) : null,
duration: formData.duration,
}

Expand Down
109 changes: 106 additions & 3 deletions assets/vue/components/basecomponents/BaseCalendar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import { usePlatformConfig } from "../../store/platformConfig"
import { calendarLocales } from "../../utils/calendarLocales"
import { useLocale } from "../../composables/locale"
import { usePrimeVue } from "primevue/config"
import { useI18n } from "vue-i18n"

const { t } = useI18n()
const platformConfigStore = usePlatformConfig()
const timepicketIncrement = Number(platformConfigStore.getSetting("platform.timepicker_increment"))

Expand All @@ -17,10 +19,23 @@ const modelValue = defineModel({
default: null,
})

// Internal value used by the DatePicker
const internalValue = ref(modelValue.value)

// Sync internal value when the external model changes (e.g. reset from parent)
watch(
() => modelValue.value,
(newValue) => {
internalValue.value = newValue
},
)

const datepickerRef = ref(null)

const { appLocale } = useLocale()
const localePrefix = ref(getLocalePrefix(appLocale.value))

defineProps({
const props = defineProps({
label: {
type: String,
required: true,
Expand Down Expand Up @@ -85,12 +100,54 @@ onMounted(() => {
primevue.config.locale = selectedLocale.value
}
})

// When showTime is false, we keep the old behavior: update parent immediately
watch(
() => internalValue.value,
(newValue) => {
if (!props.showTime) {
modelValue.value = newValue
}
},
)

// Safely hide the calendar overlay (PrimeVue internal API)
const hideOverlay = () => {
const instance = datepickerRef.value
if (!instance) {
return
}

// PrimeVue DatePicker exposes overlayVisible / hideOverlay in runtime instance
if (typeof instance.hideOverlay === "function") {
instance.hideOverlay()
return
}

if ("overlayVisible" in instance) {
instance.overlayVisible = false
}
}

// User confirms the current selection
const onApplyClick = () => {
modelValue.value = internalValue.value
hideOverlay()
}

// User cancels the selection and restores external value
const onCancelClick = () => {
internalValue.value = modelValue.value
hideOverlay()
}
</script>

<template>
<div class="field">
<FloatLabel variant="on">
<DatePicker
v-model="modelValue"
ref="datepickerRef"
v-model="internalValue"
:date-format="dateFormat"
:input-id="id"
:invalid="isInvalid"
Expand All @@ -102,7 +159,30 @@ onMounted(() => {
fluid
icon-display="input"
show-icon
/>
>
<!-- Custom footer only when using time selection -->
<template
v-if="showTime"
#footer
>
<div class="base-calendar-footer">
<button
type="button"
class="base-calendar-footer__button base-calendar-footer__button--secondary"
@click="onCancelClick"
>
{{ t("Cancel") }}
</button>
<button
type="button"
class="base-calendar-footer__button base-calendar-footer__button--primary"
@click="onApplyClick"
>
{{ t("Ok") }}
</button>
</div>
</template>
</DatePicker>
<label
:for="id"
v-text="label"
Expand All @@ -118,3 +198,26 @@ onMounted(() => {
</Message>
</div>
</template>
<style scoped>
.base-calendar-footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding: 0.5rem 0.75rem 0.75rem;
}
.base-calendar-footer__button {
border-radius: 9999px;
padding: 0.25rem 0.75rem;
font-size: 0.75rem;
border: 1px solid transparent;
cursor: pointer;
}
.base-calendar-footer__button--secondary {
background-color: transparent;
border-color: var(--gray-40, #d4d4d4);
}
.base-calendar-footer__button--primary {
background-color: var(--primary-color, #0d9488);
color: #ffffff;
}
</style>
Loading
Loading