feat : refacto de la partie calendrier + ajout de validation sur les formulaires + ajout des jours fériés

This commit is contained in:
2026-02-09 14:25:18 +01:00
parent 03f5552dd4
commit c1025d6066
18 changed files with 1303 additions and 427 deletions

6
.idea/SIRH.iml generated
View File

@@ -136,6 +136,12 @@
<excludeFolder url="file://$MODULE_DIR$/vendor/willdurand/negotiation" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/http-client" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/http-client-contracts" />
<excludeFolder url="file://$MODULE_DIR$/vendor/dompdf/dompdf" />
<excludeFolder url="file://$MODULE_DIR$/vendor/dompdf/php-font-lib" />
<excludeFolder url="file://$MODULE_DIR$/vendor/dompdf/php-svg-lib" />
<excludeFolder url="file://$MODULE_DIR$/vendor/masterminds/html5" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sabberworm/php-css-parser" />
<excludeFolder url="file://$MODULE_DIR$/vendor/thecodingmachine/safe" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />

View File

@@ -1,10 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MaterialThemeProjectNewConfig">
<option name="metadata">
<MTProjectMetadataState>
<option name="userId" value="-7cf7a629:19c1e9ce3f8:-7e79" />
</MTProjectMetadataState>
</option>
</component>
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MaterialThemeProjectNewConfig">
<option name="metadata">
<MTProjectMetadataState>
<option name="migrated" value="true" />
<option name="pristineConfig" value="false" />
<option name="userId" value="-3bc0fa3e:19bc6e06872:-7ff9" />
</MTProjectMetadataState>
</option>
</component>
</project>

326
.idea/php.xml generated
View File

@@ -1,161 +1,167 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MessDetectorOptionsConfiguration">
<option name="transferred" value="true" />
</component>
<component name="PHPCSFixerOptionsConfiguration">
<option name="transferred" value="true" />
</component>
<component name="PHPCodeSnifferOptionsConfiguration">
<option name="highlightLevel" value="WARNING" />
<option name="transferred" value="true" />
</component>
<component name="PhpIncludePathManager">
<include_path>
<path value="$PROJECT_DIR$/vendor/composer" />
<path value="$PROJECT_DIR$/vendor/psr/cache" />
<path value="$PROJECT_DIR$/vendor/psr/log" />
<path value="$PROJECT_DIR$/vendor/psr/container" />
<path value="$PROJECT_DIR$/vendor/psr/clock" />
<path value="$PROJECT_DIR$/vendor/psr/link" />
<path value="$PROJECT_DIR$/vendor/psr/event-dispatcher" />
<path value="$PROJECT_DIR$/vendor/clue/ndjson-react" />
<path value="$PROJECT_DIR$/vendor/twig/twig" />
<path value="$PROJECT_DIR$/vendor/fidry/cpu-core-counter" />
<path value="$PROJECT_DIR$/vendor/lexik/jwt-authentication-bundle" />
<path value="$PROJECT_DIR$/vendor/nikic/php-parser" />
<path value="$PROJECT_DIR$/vendor/react/cache" />
<path value="$PROJECT_DIR$/vendor/react/stream" />
<path value="$PROJECT_DIR$/vendor/react/socket" />
<path value="$PROJECT_DIR$/vendor/react/event-loop" />
<path value="$PROJECT_DIR$/vendor/react/child-process" />
<path value="$PROJECT_DIR$/vendor/react/dns" />
<path value="$PROJECT_DIR$/vendor/react/promise" />
<path value="$PROJECT_DIR$/vendor/nelmio/cors-bundle" />
<path value="$PROJECT_DIR$/vendor/staabm/side-effects-detector" />
<path value="$PROJECT_DIR$/vendor/myclabs/deep-copy" />
<path value="$PROJECT_DIR$/vendor/phar-io/version" />
<path value="$PROJECT_DIR$/vendor/phar-io/manifest" />
<path value="$PROJECT_DIR$/vendor/phpstan/phpdoc-parser" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-timer" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-code-coverage" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-text-template" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-invoker" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-file-iterator" />
<path value="$PROJECT_DIR$/vendor/phpunit/phpunit" />
<path value="$PROJECT_DIR$/vendor/symfony/cache" />
<path value="$PROJECT_DIR$/vendor/symfony/security-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-mbstring" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-uuid" />
<path value="$PROJECT_DIR$/vendor/symfony/http-kernel" />
<path value="$PROJECT_DIR$/vendor/symfony/expression-language" />
<path value="$PROJECT_DIR$/vendor/symfony/var-dumper" />
<path value="$PROJECT_DIR$/vendor/symfony/console" />
<path value="$PROJECT_DIR$/vendor/symfony/twig-bridge" />
<path value="$PROJECT_DIR$/vendor/symfony/stopwatch" />
<path value="$PROJECT_DIR$/vendor/symfony/serializer" />
<path value="$PROJECT_DIR$/vendor/symfony/http-foundation" />
<path value="$PROJECT_DIR$/vendor/symfony/clock" />
<path value="$PROJECT_DIR$/vendor/symfony/deprecation-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/yaml" />
<path value="$PROJECT_DIR$/vendor/symfony/property-access" />
<path value="$PROJECT_DIR$/vendor/symfony/password-hasher" />
<path value="$PROJECT_DIR$/vendor/symfony/var-exporter" />
<path value="$PROJECT_DIR$/vendor/symfony/routing" />
<path value="$PROJECT_DIR$/vendor/symfony/event-dispatcher-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-normalizer" />
<path value="$PROJECT_DIR$/vendor/symfony/security-core" />
<path value="$PROJECT_DIR$/vendor/symfony/security-csrf" />
<path value="$PROJECT_DIR$/vendor/symfony/error-handler" />
<path value="$PROJECT_DIR$/vendor/symfony/dotenv" />
<path value="$PROJECT_DIR$/vendor/symfony/property-info" />
<path value="$PROJECT_DIR$/vendor/symfony/runtime" />
<path value="$PROJECT_DIR$/vendor/symfony/flex" />
<path value="$PROJECT_DIR$/vendor/symfony/config" />
<path value="$PROJECT_DIR$/vendor/symfony/finder" />
<path value="$PROJECT_DIR$/vendor/symfony/asset" />
<path value="$PROJECT_DIR$/vendor/symfony/twig-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/doctrine-bridge" />
<path value="$PROJECT_DIR$/vendor/symfony/dependency-injection" />
<path value="$PROJECT_DIR$/vendor/symfony/string" />
<path value="$PROJECT_DIR$/vendor/symfony/translation-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-php85" />
<path value="$PROJECT_DIR$/vendor/symfony/filesystem" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-grapheme" />
<path value="$PROJECT_DIR$/vendor/symfony/type-info" />
<path value="$PROJECT_DIR$/vendor/symfony/web-link" />
<path value="$PROJECT_DIR$/vendor/symfony/framework-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/validator" />
<path value="$PROJECT_DIR$/vendor/symfony/options-resolver" />
<path value="$PROJECT_DIR$/vendor/symfony/process" />
<path value="$PROJECT_DIR$/vendor/symfony/event-dispatcher" />
<path value="$PROJECT_DIR$/vendor/symfony/uid" />
<path value="$PROJECT_DIR$/vendor/symfony/security-http" />
<path value="$PROJECT_DIR$/vendor/symfony/cache-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/service-contracts" />
<path value="$PROJECT_DIR$/vendor/theseer/tokenizer" />
<path value="$PROJECT_DIR$/vendor/doctrine/inflector" />
<path value="$PROJECT_DIR$/vendor/doctrine/deprecations" />
<path value="$PROJECT_DIR$/vendor/doctrine/common" />
<path value="$PROJECT_DIR$/vendor/doctrine/dbal" />
<path value="$PROJECT_DIR$/vendor/doctrine/sql-formatter" />
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-migrations-bundle" />
<path value="$PROJECT_DIR$/vendor/doctrine/lexer" />
<path value="$PROJECT_DIR$/vendor/doctrine/orm" />
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-bundle" />
<path value="$PROJECT_DIR$/vendor/doctrine/collections" />
<path value="$PROJECT_DIR$/vendor/doctrine/persistence" />
<path value="$PROJECT_DIR$/vendor/doctrine/migrations" />
<path value="$PROJECT_DIR$/vendor/doctrine/instantiator" />
<path value="$PROJECT_DIR$/vendor/doctrine/event-manager" />
<path value="$PROJECT_DIR$/vendor/lcobucci/jwt" />
<path value="$PROJECT_DIR$/vendor/evenement/evenement" />
<path value="$PROJECT_DIR$/vendor/sebastian/comparator" />
<path value="$PROJECT_DIR$/vendor/sebastian/object-reflector" />
<path value="$PROJECT_DIR$/vendor/sebastian/environment" />
<path value="$PROJECT_DIR$/vendor/sebastian/complexity" />
<path value="$PROJECT_DIR$/vendor/sebastian/object-enumerator" />
<path value="$PROJECT_DIR$/vendor/sebastian/cli-parser" />
<path value="$PROJECT_DIR$/vendor/sebastian/diff" />
<path value="$PROJECT_DIR$/vendor/sebastian/version" />
<path value="$PROJECT_DIR$/vendor/sebastian/exporter" />
<path value="$PROJECT_DIR$/vendor/sebastian/recursion-context" />
<path value="$PROJECT_DIR$/vendor/sebastian/global-state" />
<path value="$PROJECT_DIR$/vendor/sebastian/type" />
<path value="$PROJECT_DIR$/vendor/sebastian/lines-of-code" />
<path value="$PROJECT_DIR$/vendor/webmozart/assert" />
<path value="$PROJECT_DIR$/vendor/willdurand/negotiation" />
<path value="$PROJECT_DIR$/vendor/api-platform/openapi" />
<path value="$PROJECT_DIR$/vendor/api-platform/http-cache" />
<path value="$PROJECT_DIR$/vendor/api-platform/doctrine-orm" />
<path value="$PROJECT_DIR$/vendor/api-platform/json-schema" />
<path value="$PROJECT_DIR$/vendor/api-platform/serializer" />
<path value="$PROJECT_DIR$/vendor/api-platform/hydra" />
<path value="$PROJECT_DIR$/vendor/api-platform/metadata" />
<path value="$PROJECT_DIR$/vendor/api-platform/jsonld" />
<path value="$PROJECT_DIR$/vendor/api-platform/validator" />
<path value="$PROJECT_DIR$/vendor/api-platform/symfony" />
<path value="$PROJECT_DIR$/vendor/api-platform/doctrine-common" />
<path value="$PROJECT_DIR$/vendor/api-platform/documentation" />
<path value="$PROJECT_DIR$/vendor/api-platform/state" />
<path value="$PROJECT_DIR$/vendor/friendsofphp/php-cs-fixer" />
<path value="$PROJECT_DIR$/vendor/phpdocumentor/reflection-docblock" />
<path value="$PROJECT_DIR$/vendor/phpdocumentor/reflection-common" />
<path value="$PROJECT_DIR$/vendor/phpdocumentor/type-resolver" />
<path value="$PROJECT_DIR$/vendor/symfony/http-client-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/http-client" />
</include_path>
</component>
<component name="PhpProjectSharedConfiguration" php_language_level="8.4" />
<component name="PhpStanOptionsConfiguration">
<option name="transferred" value="true" />
</component>
<component name="PhpUnit">
<phpunit_settings>
<PhpUnitSettings custom_loader_path="$PROJECT_DIR$/vendor/autoload.php" />
</phpunit_settings>
</component>
<component name="PsalmOptionsConfiguration">
<option name="transferred" value="true" />
</component>
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MessDetectorOptionsConfiguration">
<option name="transferred" value="true" />
</component>
<component name="PHPCSFixerOptionsConfiguration">
<option name="transferred" value="true" />
</component>
<component name="PHPCodeSnifferOptionsConfiguration">
<option name="highlightLevel" value="WARNING" />
<option name="transferred" value="true" />
</component>
<component name="PhpIncludePathManager">
<include_path>
<path value="$PROJECT_DIR$/vendor/composer" />
<path value="$PROJECT_DIR$/vendor/psr/cache" />
<path value="$PROJECT_DIR$/vendor/psr/log" />
<path value="$PROJECT_DIR$/vendor/psr/container" />
<path value="$PROJECT_DIR$/vendor/psr/clock" />
<path value="$PROJECT_DIR$/vendor/psr/link" />
<path value="$PROJECT_DIR$/vendor/psr/event-dispatcher" />
<path value="$PROJECT_DIR$/vendor/clue/ndjson-react" />
<path value="$PROJECT_DIR$/vendor/twig/twig" />
<path value="$PROJECT_DIR$/vendor/fidry/cpu-core-counter" />
<path value="$PROJECT_DIR$/vendor/lexik/jwt-authentication-bundle" />
<path value="$PROJECT_DIR$/vendor/nikic/php-parser" />
<path value="$PROJECT_DIR$/vendor/react/cache" />
<path value="$PROJECT_DIR$/vendor/react/stream" />
<path value="$PROJECT_DIR$/vendor/react/socket" />
<path value="$PROJECT_DIR$/vendor/react/event-loop" />
<path value="$PROJECT_DIR$/vendor/react/child-process" />
<path value="$PROJECT_DIR$/vendor/react/dns" />
<path value="$PROJECT_DIR$/vendor/react/promise" />
<path value="$PROJECT_DIR$/vendor/nelmio/cors-bundle" />
<path value="$PROJECT_DIR$/vendor/staabm/side-effects-detector" />
<path value="$PROJECT_DIR$/vendor/myclabs/deep-copy" />
<path value="$PROJECT_DIR$/vendor/phar-io/version" />
<path value="$PROJECT_DIR$/vendor/phar-io/manifest" />
<path value="$PROJECT_DIR$/vendor/phpstan/phpdoc-parser" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-timer" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-code-coverage" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-text-template" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-invoker" />
<path value="$PROJECT_DIR$/vendor/phpunit/php-file-iterator" />
<path value="$PROJECT_DIR$/vendor/phpunit/phpunit" />
<path value="$PROJECT_DIR$/vendor/symfony/cache" />
<path value="$PROJECT_DIR$/vendor/symfony/security-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-mbstring" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-uuid" />
<path value="$PROJECT_DIR$/vendor/symfony/http-kernel" />
<path value="$PROJECT_DIR$/vendor/symfony/expression-language" />
<path value="$PROJECT_DIR$/vendor/symfony/var-dumper" />
<path value="$PROJECT_DIR$/vendor/symfony/console" />
<path value="$PROJECT_DIR$/vendor/symfony/twig-bridge" />
<path value="$PROJECT_DIR$/vendor/symfony/stopwatch" />
<path value="$PROJECT_DIR$/vendor/symfony/serializer" />
<path value="$PROJECT_DIR$/vendor/symfony/http-foundation" />
<path value="$PROJECT_DIR$/vendor/symfony/clock" />
<path value="$PROJECT_DIR$/vendor/symfony/deprecation-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/yaml" />
<path value="$PROJECT_DIR$/vendor/symfony/property-access" />
<path value="$PROJECT_DIR$/vendor/symfony/password-hasher" />
<path value="$PROJECT_DIR$/vendor/symfony/var-exporter" />
<path value="$PROJECT_DIR$/vendor/symfony/routing" />
<path value="$PROJECT_DIR$/vendor/symfony/event-dispatcher-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-normalizer" />
<path value="$PROJECT_DIR$/vendor/symfony/security-core" />
<path value="$PROJECT_DIR$/vendor/symfony/security-csrf" />
<path value="$PROJECT_DIR$/vendor/symfony/error-handler" />
<path value="$PROJECT_DIR$/vendor/symfony/dotenv" />
<path value="$PROJECT_DIR$/vendor/symfony/property-info" />
<path value="$PROJECT_DIR$/vendor/symfony/runtime" />
<path value="$PROJECT_DIR$/vendor/symfony/flex" />
<path value="$PROJECT_DIR$/vendor/symfony/config" />
<path value="$PROJECT_DIR$/vendor/symfony/finder" />
<path value="$PROJECT_DIR$/vendor/symfony/asset" />
<path value="$PROJECT_DIR$/vendor/symfony/twig-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/doctrine-bridge" />
<path value="$PROJECT_DIR$/vendor/symfony/dependency-injection" />
<path value="$PROJECT_DIR$/vendor/symfony/string" />
<path value="$PROJECT_DIR$/vendor/symfony/translation-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-php85" />
<path value="$PROJECT_DIR$/vendor/symfony/filesystem" />
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-grapheme" />
<path value="$PROJECT_DIR$/vendor/symfony/type-info" />
<path value="$PROJECT_DIR$/vendor/symfony/web-link" />
<path value="$PROJECT_DIR$/vendor/symfony/framework-bundle" />
<path value="$PROJECT_DIR$/vendor/symfony/validator" />
<path value="$PROJECT_DIR$/vendor/symfony/options-resolver" />
<path value="$PROJECT_DIR$/vendor/symfony/process" />
<path value="$PROJECT_DIR$/vendor/symfony/event-dispatcher" />
<path value="$PROJECT_DIR$/vendor/symfony/uid" />
<path value="$PROJECT_DIR$/vendor/symfony/security-http" />
<path value="$PROJECT_DIR$/vendor/symfony/cache-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/service-contracts" />
<path value="$PROJECT_DIR$/vendor/theseer/tokenizer" />
<path value="$PROJECT_DIR$/vendor/doctrine/inflector" />
<path value="$PROJECT_DIR$/vendor/doctrine/deprecations" />
<path value="$PROJECT_DIR$/vendor/doctrine/common" />
<path value="$PROJECT_DIR$/vendor/doctrine/dbal" />
<path value="$PROJECT_DIR$/vendor/doctrine/sql-formatter" />
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-migrations-bundle" />
<path value="$PROJECT_DIR$/vendor/doctrine/lexer" />
<path value="$PROJECT_DIR$/vendor/doctrine/orm" />
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-bundle" />
<path value="$PROJECT_DIR$/vendor/doctrine/collections" />
<path value="$PROJECT_DIR$/vendor/doctrine/persistence" />
<path value="$PROJECT_DIR$/vendor/doctrine/migrations" />
<path value="$PROJECT_DIR$/vendor/doctrine/instantiator" />
<path value="$PROJECT_DIR$/vendor/doctrine/event-manager" />
<path value="$PROJECT_DIR$/vendor/lcobucci/jwt" />
<path value="$PROJECT_DIR$/vendor/evenement/evenement" />
<path value="$PROJECT_DIR$/vendor/sebastian/comparator" />
<path value="$PROJECT_DIR$/vendor/sebastian/object-reflector" />
<path value="$PROJECT_DIR$/vendor/sebastian/environment" />
<path value="$PROJECT_DIR$/vendor/sebastian/complexity" />
<path value="$PROJECT_DIR$/vendor/sebastian/object-enumerator" />
<path value="$PROJECT_DIR$/vendor/sebastian/cli-parser" />
<path value="$PROJECT_DIR$/vendor/sebastian/diff" />
<path value="$PROJECT_DIR$/vendor/sebastian/version" />
<path value="$PROJECT_DIR$/vendor/sebastian/exporter" />
<path value="$PROJECT_DIR$/vendor/sebastian/recursion-context" />
<path value="$PROJECT_DIR$/vendor/sebastian/global-state" />
<path value="$PROJECT_DIR$/vendor/sebastian/type" />
<path value="$PROJECT_DIR$/vendor/sebastian/lines-of-code" />
<path value="$PROJECT_DIR$/vendor/webmozart/assert" />
<path value="$PROJECT_DIR$/vendor/willdurand/negotiation" />
<path value="$PROJECT_DIR$/vendor/api-platform/openapi" />
<path value="$PROJECT_DIR$/vendor/api-platform/http-cache" />
<path value="$PROJECT_DIR$/vendor/api-platform/doctrine-orm" />
<path value="$PROJECT_DIR$/vendor/api-platform/json-schema" />
<path value="$PROJECT_DIR$/vendor/api-platform/serializer" />
<path value="$PROJECT_DIR$/vendor/api-platform/hydra" />
<path value="$PROJECT_DIR$/vendor/api-platform/metadata" />
<path value="$PROJECT_DIR$/vendor/api-platform/jsonld" />
<path value="$PROJECT_DIR$/vendor/api-platform/validator" />
<path value="$PROJECT_DIR$/vendor/api-platform/symfony" />
<path value="$PROJECT_DIR$/vendor/api-platform/doctrine-common" />
<path value="$PROJECT_DIR$/vendor/api-platform/documentation" />
<path value="$PROJECT_DIR$/vendor/api-platform/state" />
<path value="$PROJECT_DIR$/vendor/friendsofphp/php-cs-fixer" />
<path value="$PROJECT_DIR$/vendor/phpdocumentor/reflection-docblock" />
<path value="$PROJECT_DIR$/vendor/phpdocumentor/reflection-common" />
<path value="$PROJECT_DIR$/vendor/phpdocumentor/type-resolver" />
<path value="$PROJECT_DIR$/vendor/symfony/http-client-contracts" />
<path value="$PROJECT_DIR$/vendor/symfony/http-client" />
<path value="$PROJECT_DIR$/vendor/dompdf/php-font-lib" />
<path value="$PROJECT_DIR$/vendor/sabberworm/php-css-parser" />
<path value="$PROJECT_DIR$/vendor/dompdf/php-svg-lib" />
<path value="$PROJECT_DIR$/vendor/dompdf/dompdf" />
<path value="$PROJECT_DIR$/vendor/thecodingmachine/safe" />
<path value="$PROJECT_DIR$/vendor/masterminds/html5" />
</include_path>
</component>
<component name="PhpProjectSharedConfiguration" php_language_level="8.4" />
<component name="PhpStanOptionsConfiguration">
<option name="transferred" value="true" />
</component>
<component name="PhpUnit">
<phpunit_settings>
<PhpUnitSettings custom_loader_path="$PROJECT_DIR$/vendor/autoload.php" />
</phpunit_settings>
</component>
<component name="PsalmOptionsConfiguration">
<option name="transferred" value="true" />
</component>
</project>

View File

@@ -0,0 +1,201 @@
<template>
<AppDrawer v-model="drawerOpen" title="Nouvelle absence">
<form class="space-y-4" @submit.prevent="handleSubmit">
<div>
<label class="text-md font-semibold text-neutral-700" for="employee">
Employé <span class="text-red-600">*</span>
</label>
<select
id="employee"
v-model="absenceForm.employeeId"
:class="employeeFieldClass"
>
<option value="" disabled>Choisir un employé</option>
<option v-for="employee in employees" :key="employee.id" :value="employee.id">
{{ employee.firstName }} {{ employee.lastName }}
</option>
</select>
<p v-if="showEmployeeError" class="mt-1 text-sm text-red-600">
L'employé est obligatoire.
</p>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="type">
Type d'absence <span class="text-red-600">*</span>
</label>
<select
id="type"
v-model="absenceForm.typeId"
:class="typeFieldClass"
>
<option value="" disabled>Choisir un type</option>
<option v-for="type in absenceTypes" :key="type.id" :value="type.id">
{{ type.label }} ({{ type.code }})
</option>
</select>
<p v-if="showTypeError" class="mt-1 text-sm text-red-600">
Le type d'absence est obligatoire.
</p>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="text-md font-semibold text-neutral-700" for="start-date">Date de début</label>
<input
id="start-date"
v-model="absenceForm.startDate"
type="date"
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900"
/>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="end-date">Date de fin</label>
<input
id="end-date"
v-model="absenceForm.endDate"
type="date"
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900"
/>
</div>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="comment">Commentaire</label>
<textarea
id="comment"
v-model="absenceForm.comment"
rows="3"
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900"
/>
</div>
<div class="flex justify-end gap-3 pt-2">
<button
v-if="editingAbsence"
type="button"
class="rounded-lg border border-red-200 px-4 py-2 text-md font-semibold text-red-600 hover:bg-red-50"
@click="handleDelete"
>
Supprimer
</button>
<button
type="button"
class="rounded-lg border border-neutral-200 px-4 py-2 text-md font-semibold text-neutral-700 hover:bg-neutral-100"
@click="handleCancel"
>
Annuler
</button>
<button
type="submit"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
:class="submitButtonClass"
>
Enregistrer
</button>
</div>
</form>
</AppDrawer>
</template>
<script setup lang="ts">
import { computed, reactive, toRef, watch } from 'vue'
import type { Employee } from '~/services/dto/employee'
import type { AbsenceType } from '~/services/dto/absence-type'
import type { Absence } from '~/services/dto/absence'
import AppDrawer from '~/components/AppDrawer.vue'
const props = defineProps<{
modelValue: boolean
employees: Employee[]
absenceTypes: AbsenceType[]
form: {
employeeId: number | ''
typeId: number | ''
startDate: string
endDate: string
comment: string
}
editingAbsence: Absence | null
isSubmitting: boolean
}>()
const emit = defineEmits<{
(event: 'update:modelValue', value: boolean): void
(event: 'submit'): void
(event: 'delete'): void
(event: 'cancel'): void
}>()
const drawerOpen = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value)
})
const absenceForm = toRef(props, 'form')
const editingAbsence = toRef(props, 'editingAbsence')
const validationTouched = reactive({
employee: false,
type: false
})
const isEmployeeValid = computed(() => absenceForm.value.employeeId !== '')
const isTypeValid = computed(() => absenceForm.value.typeId !== '')
const isFormValid = computed(() => isEmployeeValid.value && isTypeValid.value)
const showEmployeeError = computed(
() => validationTouched.employee && !isEmployeeValid.value
)
const showTypeError = computed(
() => validationTouched.type && !isTypeValid.value
)
const submitButtonClass = computed(() => {
if (props.isSubmitting || !isFormValid.value) {
return 'opacity-50 cursor-not-allowed'
}
return ''
})
const baseSelectClass =
'mt-2 w-full rounded-md border bg-white px-3 py-2 text-md text-neutral-900'
const employeeFieldClass = computed(() => {
if (showEmployeeError.value) {
return `${baseSelectClass} border-red-500`
}
return `${baseSelectClass} border-neutral-300`
})
const typeFieldClass = computed(() => {
if (showTypeError.value) {
return `${baseSelectClass} border-red-500`
}
return `${baseSelectClass} border-neutral-300`
})
watch(
() => props.modelValue,
(isOpen) => {
if (!isOpen) {
validationTouched.employee = false
validationTouched.type = false
}
}
)
const handleSubmit = () => {
validationTouched.employee = true
validationTouched.type = true
if (!isEmployeeValid.value || !isTypeValid.value) return
emit('submit')
}
const handleDelete = () => {
emit('delete')
}
const handleCancel = () => {
emit('cancel')
emit('update:modelValue', false)
}
</script>

View File

@@ -0,0 +1,172 @@
<template>
<AppDrawer v-model="drawerOpen" title="Imprimer les absences">
<form class="space-y-4" @submit.prevent="handleSubmit">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="text-md font-semibold text-neutral-700" for="print-from">
Date de début <span class="text-red-600">*</span>
</label>
<input
id="print-from"
v-model="printForm.from"
type="date"
:class="fromFieldClass"
/>
<p v-if="showFromError" class="mt-1 text-sm text-red-600">
La date de début est obligatoire.
</p>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="print-to">
Date de fin <span class="text-red-600">*</span>
</label>
<input
id="print-to"
v-model="printForm.to"
type="date"
:class="toFieldClass"
/>
<p v-if="showToError" class="mt-1 text-sm text-red-600">
La date de fin est obligatoire.
</p>
</div>
</div>
<div class="space-y-2">
<p class="text-md font-semibold text-neutral-700">
Sites <span class="text-red-600">*</span>
</p>
<div class="flex flex-wrap gap-4 rounded-md border border-neutral-300 px-3 py-2">
<div v-for="site in sites" :key="site.id" class="flex items-center gap-2">
<div :style="{ backgroundColor: site.color }" class="h-4 w-4 rounded"></div>
<label class="text-md" :for="`print-site-${site.id}`">{{ site.name }}</label>
<input
:id="`print-site-${site.id}`"
v-model="printForm.siteIds"
:value="site.id"
type="checkbox"
class="h-4 w-4"
/>
</div>
</div>
<p v-if="showSitesError" class="text-sm text-red-600">
Sélectionne au moins un site.
</p>
</div>
<div class="flex justify-end gap-3 pt-2">
<button
type="button"
class="rounded-lg border border-neutral-200 px-4 py-2 text-md font-semibold text-neutral-700 hover:bg-neutral-100"
@click="handleCancel"
>
Annuler
</button>
<button
type="submit"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
:class="submitButtonClass"
>
Imprimer
</button>
</div>
</form>
</AppDrawer>
</template>
<script setup lang="ts">
import { computed, reactive, toRef, watch } from 'vue'
import AppDrawer from '~/components/AppDrawer.vue'
type SiteOption = {
id: number
name: string
color: string
}
const props = defineProps<{
modelValue: boolean
sites: SiteOption[]
printForm: {
from: string
to: string
siteIds: number[]
}
}>()
const emit = defineEmits<{
(event: 'update:modelValue', value: boolean): void
(event: 'submit'): void
(event: 'cancel'): void
}>()
const drawerOpen = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value)
})
const printForm = toRef(props, 'printForm')
const validationTouched = reactive({
from: false,
to: false,
sites: false
})
const isFromValid = computed(() => printForm.value.from.trim() !== '')
const isToValid = computed(() => printForm.value.to.trim() !== '')
const isSitesValid = computed(() => printForm.value.siteIds.length > 0)
const isFormValid = computed(
() => isFromValid.value && isToValid.value && isSitesValid.value
)
const showFromError = computed(() => validationTouched.from && !isFromValid.value)
const showToError = computed(() => validationTouched.to && !isToValid.value)
const showSitesError = computed(() => validationTouched.sites && !isSitesValid.value)
const baseInputClass =
'mt-2 w-full rounded-md border px-3 py-2 text-md text-neutral-900'
const fromFieldClass = computed(() => {
if (showFromError.value) {
return `${baseInputClass} border-red-500`
}
return `${baseInputClass} border-neutral-300`
})
const toFieldClass = computed(() => {
if (showToError.value) {
return `${baseInputClass} border-red-500`
}
return `${baseInputClass} border-neutral-300`
})
const submitButtonClass = computed(() => {
if (!isFormValid.value) {
return 'opacity-50 cursor-not-allowed'
}
return ''
})
const handleSubmit = () => {
validationTouched.from = true
validationTouched.to = true
validationTouched.sites = true
if (!isFormValid.value) return
emit('submit')
}
const handleCancel = () => {
emit('cancel')
emit('update:modelValue', false)
}
watch(
() => props.modelValue,
(isOpen) => {
if (!isOpen) {
validationTouched.from = false
validationTouched.to = false
validationTouched.sites = false
}
}
)
</script>

View File

@@ -0,0 +1,76 @@
<template>
<div class="max-h-[80vh] overflow-auto rounded-lg border border-neutral-200 bg-white">
<div class="min-w-[900px]">
<div class="grid" :style="gridStyle">
<div
class="sticky left-0 z-20 border-b border-neutral-200 bg-tertiary-500 px-4 py-3 text-md font-semibold text-neutral-700"
>
Employés
</div>
<div
v-for="day in daysInMonth"
:key="day.date"
class="border-b border-neutral-200 bg-tertiary-500 px-2 py-3 text-center text-xs font-semibold text-neutral-700"
>
<div>{{ day.label }}</div>
<div class="text-[10px] text-neutral-500">{{ day.weekday }}</div>
</div>
<template v-for="employee in visibleEmployees" :key="employee.id">
<div
class="sticky left-0 z-10 border-b border-neutral-100 px-4 py-3 text-md font-semibold text-black"
:style="{ backgroundColor: employee.site?.color ?? '#304998' }"
>
{{ formatEmployeeName(employee) }}
</div>
<div
v-for="day in daysInMonth"
:key="employee.id + '-' + day.date"
class="border-b border-neutral-100 px-2 py-2 text-center text-xs text-neutral-800"
>
<button
type="button"
class="flex h-8 w-full items-center justify-center rounded-md border border-neutral-200 text-[11px] font-semibold text-neutral-800 hover:border-primary-500/40"
:class="isHolidayDate(day.date) ? 'cursor-not-allowed opacity-80' : ''"
:style="getCellStyle(employee.id, day.date)"
:disabled="isHolidayDate(day.date)"
@click="handleCellClick(employee, day.date)"
>
<span v-if="getCellCode(employee.id, day.date)">
{{ getCellCode(employee.id, day.date) }}
</span>
</button>
</div>
</template>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { Employee } from '~/services/dto/employee'
type DayInfo = {
date: string
label: string
weekday: string
}
defineProps<{
daysInMonth: DayInfo[]
visibleEmployees: Employee[]
gridStyle: Record<string, string>
getCellStyle: (employeeId: number, date: string) => Record<string, string> | undefined
getCellCode: (employeeId: number, date: string) => string
formatEmployeeName: (employee: Employee) => string
isHolidayDate: (date: string) => boolean
}>()
const emit = defineEmits<{
(event: 'cell-click', employee: Employee, date: string): void
}>()
const handleCellClick = (employee: Employee, date: string) => {
emit('cell-click', employee, date)
}
</script>

View File

@@ -1,7 +1,7 @@
<template>
<div class="min-h-screen">
<div class="flex min-h-screen">
<aside class="flex w-64 flex-col border-r border-neutral-200 bg-tertiary-500">
<div class="h-screen overflow-hidden">
<div class="flex h-full">
<aside class="flex h-full w-64 flex-shrink-0 flex-col border-r border-neutral-200 bg-tertiary-500">
<div>
<img src="/malio.png" alt="Logo" class="w-auto"/>
</div>
@@ -54,7 +54,7 @@
</div>
</aside>
<main class="flex-1 px-8 py-8">
<main class="h-full flex-1 overflow-y-auto px-8 py-8">
<slot/>
</main>
</div>

View File

@@ -66,35 +66,50 @@
<AppDrawer v-model="isDrawerOpen" :title="drawerTitle">
<form class="space-y-4" @submit.prevent="handleSubmit">
<div>
<label class="text-md font-semibold text-neutral-700" for="code">Code</label>
<label class="text-md font-semibold text-neutral-700" for="code">
Code <span class="text-red-600">*</span>
</label>
<input
id="code"
v-model="form.code"
type="text"
maxlength="10"
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200"
:class="codeFieldClass"
/>
<p v-if="showCodeError" class="mt-1 text-sm text-red-600">
Le code est obligatoire.
</p>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="label">Libellé</label>
<label class="text-md font-semibold text-neutral-700" for="label">
Libellé <span class="text-red-600">*</span>
</label>
<input
id="label"
v-model="form.label"
type="text"
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200"
:class="labelFieldClass"
/>
<p v-if="showLabelError" class="mt-1 text-sm text-red-600">
Le libellé est obligatoire.
</p>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="color">Couleur</label>
<label class="text-md font-semibold text-neutral-700" for="color">
Couleur <span class="text-red-600">*</span>
</label>
<div class="mt-2 flex items-center gap-3">
<input
id="color"
v-model="form.color"
type="color"
class="h-10 w-16 cursor-pointer rounded-md border border-neutral-300 bg-white p-1"
:class="colorFieldClass"
/>
<span class="text-md font-semibold text-neutral-600">{{ form.color }}</span>
</div>
<p v-if="showColorError" class="mt-1 text-sm text-red-600">
La couleur est obligatoire.
</p>
</div>
<div class="flex justify-end gap-3 pt-2">
<button
@@ -107,7 +122,7 @@
<button
type="submit"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
:disabled="isSubmitting"
:class="submitButtonClass"
>
Enregistrer
</button>
@@ -135,7 +150,53 @@ const drawerTitle = computed(() =>
const form = reactive({
code: '',
label: '',
color: ''
color: '#222783'
})
const validationTouched = reactive({
code: false,
label: false,
color: false
})
const isCodeValid = computed(() => form.code.trim() !== '')
const isLabelValid = computed(() => form.label.trim() !== '')
const isColorValid = computed(() => form.color.trim() !== '')
const isFormValid = computed(
() => isCodeValid.value && isLabelValid.value && isColorValid.value
)
const showCodeError = computed(() => validationTouched.code && !isCodeValid.value)
const showLabelError = computed(() => validationTouched.label && !isLabelValid.value)
const showColorError = computed(() => validationTouched.color && !isColorValid.value)
const baseInputClass =
'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200'
const codeFieldClass = computed(() => {
if (showCodeError.value) {
return `${baseInputClass} border-red-500`
}
return `${baseInputClass} border-neutral-300`
})
const labelFieldClass = computed(() => {
if (showLabelError.value) {
return `${baseInputClass} border-red-500`
}
return `${baseInputClass} border-neutral-300`
})
const colorFieldClass = computed(() => {
const baseColorClass = 'h-10 w-16 cursor-pointer rounded-md border bg-white p-1'
if (showColorError.value) {
return `${baseColorClass} border-red-500`
}
return `${baseColorClass} border-neutral-300`
})
const submitButtonClass = computed(() => {
if (isSubmitting.value || !isFormValid.value) {
return 'opacity-50 cursor-not-allowed'
}
return ''
})
const loadAbsenceTypes = async () => {
@@ -152,7 +213,7 @@ onMounted(loadAbsenceTypes)
const resetForm = () => {
form.code = ''
form.label = ''
form.color = ''
form.color = '#222783'
}
const openCreate = () => {
@@ -177,6 +238,10 @@ const closeDrawer = () => {
const handleSubmit = async () => {
if (isSubmitting.value) return
validationTouched.code = true
validationTouched.label = true
validationTouched.color = true
if (!isFormValid.value) return
isSubmitting.value = true
try {
@@ -201,6 +266,14 @@ const handleSubmit = async () => {
}
}
watch(isDrawerOpen, (isOpen) => {
if (!isOpen) {
validationTouched.code = false
validationTouched.label = false
validationTouched.color = false
}
})
const confirmDelete = async (type: AbsenceType) => {
const ok = window.confirm(`Supprimer le type ${type.label} ?`)
if (!ok) return

View File

@@ -1,8 +1,10 @@
<template>
<div>
<div class="flex flex-wrap items-center justify-between gap-4 pb-10">
<div class="flex flex-wrap items-center justify-between gap-4">
<h1 class="text-4xl font-bold text-primary-500">Calendrier des absences</h1>
<div class="flex items-center gap-3">
</div>
<div class="flex items-center justify-between py-6">
<div class="flex items-center gap-4">
<div class="flex flex-wrap items-center gap-4 rounded-md border border-neutral-300 px-3 py-2">
<div v-for="site in sites" :key="site.id" class="flex items-center gap-2">
<div :style="{ backgroundColor: site.color }" class="h-4 w-4 rounded"></div>
@@ -18,7 +20,7 @@
</div>
<select
v-model="selectedMonth"
class="rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900"
class="h-10 rounded-md border border-neutral-300 bg-white px-3 text-md text-neutral-900"
>
<option v-for="month in months" :key="month.value" :value="month.value">
{{ month.label }}
@@ -26,22 +28,24 @@
</select>
<select
v-model="selectedYear"
class="rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900"
class="h-10 rounded-md border border-neutral-300 bg-white px-3 text-md text-neutral-900"
>
<option v-for="year in years" :key="year" :value="year">
{{ year }}
</option>
</select>
</div>
<div class="flex gap-4">
<button
type="button"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
class="h-10 rounded-lg bg-primary-500 px-4 text-md font-semibold text-white hover:bg-secondary-500"
@click="openCreateFromToday"
>
Ajouter une absence
</button>
<button
type="button"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
class="h-10 rounded-lg bg-primary-500 px-4 text-md font-semibold text-white hover:bg-secondary-500"
@click="openPrint"
>
Imprimer
@@ -49,196 +53,36 @@
</div>
</div>
<div class="max-h-[80vh] overflow-auto rounded-lg border border-neutral-200 bg-white">
<div class="min-w-[900px]">
<div class="grid" :style="gridStyle">
<div
class="sticky left-0 z-20 border-b border-neutral-200 bg-tertiary-500 px-4 py-3 text-md font-semibold text-neutral-700">
Employés
</div>
<div
v-for="day in daysInMonth"
:key="day.date"
class="border-b border-neutral-200 bg-tertiary-500 px-2 py-3 text-center text-xs font-semibold text-neutral-700"
>
<div>{{ day.label }}</div>
<div class="text-[10px] text-neutral-500">{{ day.weekday }}</div>
</div>
<CalendarGrid
:days-in-month="daysInMonth"
:visible-employees="visibleEmployees"
:grid-style="gridStyle"
:get-cell-style="getCellStyle"
:get-cell-code="getCellCode"
:format-employee-name="formatEmployeeName"
:is-holiday-date="isHolidayDate"
@cell-click="openCreate"
/>
<template v-for="employee in visibleEmployees" :key="employee.id">
<div
class="sticky left-0 z-10 border-b border-neutral-100 px-4 py-3 text-md font-semibold text-black"
:style="{ backgroundColor: employee.site?.color ?? '#304998' }"
>
{{ formatEmployeeName(employee) }}
</div>
<div
v-for="day in daysInMonth"
:key="employee.id + '-' + day.date"
class="border-b border-neutral-100 px-2 py-2 text-center text-xs text-neutral-800"
>
<button
type="button"
class="flex h-8 w-full items-center justify-center rounded-md border border-neutral-200 text-[11px] font-semibold text-neutral-800 hover:border-primary-500/40"
:style="getCellStyle(employee.id, day.date)"
@click="openCreate(employee, day.date)"
>
<span v-if="getCellCode(employee.id, day.date)">
{{ getCellCode(employee.id, day.date) }}
</span>
</button>
</div>
</template>
</div>
</div>
</div>
<AbsenceFormDrawer
v-model="isDrawerOpen"
:employees="employees"
:absence-types="absenceTypes"
:form="form"
:editing-absence="editingAbsence"
:is-submitting="isSubmitting"
@submit="handleSubmit"
@delete="handleDelete"
@cancel="closeDrawer"
/>
<AppDrawer v-model="isDrawerOpen" title="Nouvelle absence">
<form class="space-y-4" @submit.prevent="handleSubmit">
<div>
<label class="text-md font-semibold text-neutral-700" for="employee">Employé</label>
<select
id="employee"
v-model="form.employeeId"
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900"
>
<option value="" disabled>Choisir un employé</option>
<option v-for="employee in employees" :key="employee.id" :value="employee.id">
{{ employee.firstName }} {{ employee.lastName }}
</option>
</select>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="type">Type d'absence</label>
<select
id="type"
v-model="form.typeId"
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900"
>
<option value="" disabled>Choisir un type</option>
<option v-for="type in absenceTypes" :key="type.id" :value="type.id">
{{ type.label }} ({{ type.code }})
</option>
</select>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="text-md font-semibold text-neutral-700" for="start-date">Date de début</label>
<input
id="start-date"
v-model="form.startDate"
type="date"
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900"
/>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="end-date">Date de fin</label>
<input
id="end-date"
v-model="form.endDate"
type="date"
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900"
/>
</div>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="comment">Commentaire</label>
<textarea
id="comment"
v-model="form.comment"
rows="3"
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900"
/>
</div>
<div class="flex justify-end gap-3 pt-2">
<button
v-if="editingAbsence"
type="button"
class="rounded-lg border border-red-200 px-4 py-2 text-md font-semibold text-red-600 hover:bg-red-50"
@click="handleDelete"
>
Supprimer
</button>
<button
type="button"
class="rounded-lg border border-neutral-200 px-4 py-2 text-md font-semibold text-neutral-700 hover:bg-neutral-100"
@click="closeDrawer"
>
Annuler
</button>
<button
type="submit"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
:disabled="isSubmitting"
>
Enregistrer
</button>
</div>
</form>
</AppDrawer>
<AppDrawer v-model="isPrintOpen" title="Imprimer les absences">
<form class="space-y-4" @submit.prevent="handlePrint">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="text-md font-semibold text-neutral-700" for="print-from">Date de début</label>
<input
id="print-from"
v-model="printForm.from"
type="date"
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900"
/>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="print-to">Date de fin</label>
<input
id="print-to"
v-model="printForm.to"
type="date"
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900"
/>
</div>
</div>
<div class="space-y-2">
<p class="text-md font-semibold text-neutral-700">Sites</p>
<div class="flex flex-wrap gap-4 rounded-md border border-neutral-300 px-3 py-2">
<div v-for="site in sites" :key="site.id" class="flex items-center gap-2">
<div :style="{ backgroundColor: site.color }" class="h-4 w-4 rounded"></div>
<label class="text-md" :for="`print-site-${site.id}`">{{ site.name }}</label>
<input
:id="`print-site-${site.id}`"
v-model="printForm.siteIds"
:value="site.id"
type="checkbox"
class="h-4 w-4"
/>
</div>
</div>
</div>
<div class="flex justify-end gap-3 pt-2">
<button
type="button"
class="rounded-lg border border-neutral-200 px-4 py-2 text-md font-semibold text-neutral-700 hover:bg-neutral-100"
@click="closePrint"
>
Annuler
</button>
<button
type="submit"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
>
Imprimer
</button>
</div>
</form>
</AppDrawer>
<AbsencePrintDrawer
v-model="isPrintOpen"
:sites="sites"
:print-form="printForm"
@submit="handlePrint"
@cancel="closePrint"
/>
</div>
</template>
@@ -249,17 +93,21 @@ import type {Absence} from '~/services/dto/absence'
import {listEmployees} from '~/services/employees'
import {listAbsenceTypes} from '~/services/absence-types'
import {createAbsence, deleteAbsence, listAbsences, updateAbsence} from '~/services/absences'
import {listPublicHolidays} from '~/services/public-holidays'
import {getDaysInMonth, normalizeDate, toYmd} from '~/utils/date'
import CalendarGrid from '~/components/CalendarGrid.vue'
import AbsenceFormDrawer from '~/components/AbsenceFormDrawer.vue'
import AbsencePrintDrawer from '~/components/AbsencePrintDrawer.vue'
const employees = ref<Employee[]>([])
const sites = computed(() => {
const map = new Map<number, { id: number; name: string; color: string }>()
const siteMap = new Map<number, { id: number; name: string; color: string }>()
for (const employee of employees.value) {
if (employee.site) {
map.set(employee.site.id, employee.site)
siteMap.set(employee.site.id, employee.site)
}
}
return Array.from(map.values()).sort((a, b) => a.name.localeCompare(b.name, 'fr'))
return Array.from(siteMap.values()).sort((siteA, siteB) => siteA.name.localeCompare(siteB.name, 'fr'))
})
const selectedSiteIds = ref<number[]>([])
@@ -272,16 +120,16 @@ watch(sites, (next) => {
}, { immediate: true })
const sortedEmployees = computed(() => {
return [...employees.value].sort((a, b) => {
const siteA = a.site?.name ?? ''
const siteB = b.site?.name ?? ''
if (siteA !== siteB) return siteA.localeCompare(siteB, 'fr')
const lastA = a.lastName ?? ''
const lastB = b.lastName ?? ''
if (lastA !== lastB) return lastA.localeCompare(lastB, 'fr')
const firstA = a.firstName ?? ''
const firstB = b.firstName ?? ''
return firstA.localeCompare(firstB, 'fr')
return [...employees.value].sort((employeeA, employeeB) => {
const siteNameA = employeeA.site?.name ?? ''
const siteNameB = employeeB.site?.name ?? ''
if (siteNameA !== siteNameB) return siteNameA.localeCompare(siteNameB, 'fr')
const lastNameA = employeeA.lastName ?? ''
const lastNameB = employeeB.lastName ?? ''
if (lastNameA !== lastNameB) return lastNameA.localeCompare(lastNameB, 'fr')
const firstNameA = employeeA.firstName ?? ''
const firstNameB = employeeB.firstName ?? ''
return firstNameA.localeCompare(firstNameB, 'fr')
})
})
@@ -293,6 +141,7 @@ const visibleEmployees = computed(() => {
})
const absenceTypes = ref<AbsenceType[]>([])
const absences = ref<Absence[]>([])
const publicHolidays = ref<Record<string, string>>({})
const isDrawerOpen = ref(false)
const isSubmitting = ref(false)
@@ -318,10 +167,12 @@ const months = [
{value: 11, label: 'Décembre'}
]
const years = Array.from({length: 5}, (_, i) => now.getFullYear() - 2 + i)
const years = Array.from({length: 5}, (unusedValue, index) => now.getFullYear() - 2 + index)
const daysInMonth = computed(() => getDaysInMonth(selectedYear.value, selectedMonth.value))
const monthStartDate = computed(() => new Date(selectedYear.value, selectedMonth.value, 1))
const monthEndDate = computed(() => new Date(selectedYear.value, selectedMonth.value + 1, 0))
const gridStyle = computed(() => ({
gridTemplateColumns: `220px repeat(${daysInMonth.value.length}, minmax(44px, 1fr))`
@@ -423,33 +274,82 @@ const loadAbsenceTypes = async () => {
absenceTypes.value = await listAbsenceTypes()
}
const loadPublicHolidays = async () => {
publicHolidays.value = await listPublicHolidays('metropole', selectedYear.value)
}
const loadAbsences = async () => {
absences.value = await listAbsences()
const monthStart = toYmd(selectedYear.value, selectedMonth.value, 1)
const monthEnd = toYmd(selectedYear.value, selectedMonth.value + 1, 0)
absences.value = await listAbsences({
from: monthStart,
to: monthEnd,
siteIds: selectedSiteIds.value
})
}
onMounted(async () => {
await Promise.all([loadEmployees(), loadAbsenceTypes(), loadAbsences()])
await Promise.all([loadEmployees(), loadAbsenceTypes(), loadPublicHolidays(), loadAbsences()])
})
watch([selectedMonth, selectedYear], async () => {
watch([selectedMonth, selectedYear, selectedSiteIds], async () => {
await loadAbsences()
})
const getCellAbsence = (employeeId: number, date: string) => {
const match = absences.value.find((absence) => {
const employee = absence.employee?.id
const start = normalizeDate(absence.startDate)
const end = normalizeDate(absence.endDate)
return Number(employee) === employeeId && date >= start && date <= end
})
watch(selectedYear, async () => {
await loadPublicHolidays()
})
if (!match) return null
// Indexation des absences par cellule pour eviter un find() a chaque case.
const cellAbsenceMap = computed(() => {
const map = new Map<string, { id: number; code: string; color: string; textColor?: string }>()
const monthStart = monthStartDate.value
const monthEnd = monthEndDate.value
return {
id: match.id,
code: match.type?.code ?? '',
color: match.type?.color ?? '#222783'
for (const absence of absences.value) {
const employeeId = absence.employee?.id
if (!employeeId) continue
const start = parseYmd(normalizeDate(absence.startDate))
const end = parseYmd(normalizeDate(absence.endDate))
if (!start || !end) continue
const rangeStart = start < monthStart ? monthStart : start
const rangeEnd = end > monthEnd ? monthEnd : end
if (rangeEnd < rangeStart) continue
for (
let currentDate = new Date(rangeStart.getTime());
currentDate <= rangeEnd;
currentDate.setDate(currentDate.getDate() + 1)
) {
const key = `${employeeId}-${toYmd(currentDate.getFullYear(), currentDate.getMonth(), currentDate.getDate())}`
map.set(key, {
id: absence.id,
code: absence.type?.code ?? '',
color: absence.type?.color ?? '#222783'
})
}
}
return map
})
const isHolidayDate = (date: string) => {
return Boolean(publicHolidays.value[date])
}
const getCellAbsence = (employeeId: number, date: string) => {
if (isHolidayDate(date)) {
return {
id: 0,
code: 'F',
color: '#b3e5fc',
textColor: '#0f172a'
}
}
const absence = cellAbsenceMap.value.get(`${employeeId}-${date}`)
if (absence) return absence
return null
}
const getCellStyle = (employeeId: number, date: string) => {
@@ -458,7 +358,7 @@ const getCellStyle = (employeeId: number, date: string) => {
return {
backgroundColor: absence.color,
color: '#fff'
color: absence.textColor ?? '#fff'
}
}
@@ -467,6 +367,11 @@ const getCellCode = (employeeId: number, date: string) => {
}
const openCreate = (employee: Employee, date: string) => {
if (isHolidayDate(date)) {
window.alert("Impossible de creer une absence un jour ferie.")
return
}
const existing = absences.value.find((absence) => {
const start = normalizeDate(absence.startDate)
const end = normalizeDate(absence.endDate)
@@ -498,12 +403,33 @@ const openCreateFromToday = () => {
form.typeId = ''
const now = new Date()
const today = toYmd(now.getFullYear(), now.getMonth(), now.getDate())
if (isHolidayDate(today)) {
window.alert("Impossible de creer une absence un jour ferie.")
return
}
form.startDate = today
form.endDate = today
form.comment = ''
isDrawerOpen.value = true
}
const hasHolidayInRange = (startDate: string, endDate: string) => {
const start = parseYmd(startDate)
const end = parseYmd(endDate)
if (!start || !end) return false
for (
let currentDate = new Date(start.getTime());
currentDate <= end;
currentDate.setDate(currentDate.getDate() + 1)
) {
const key = toYmd(currentDate.getFullYear(), currentDate.getMonth(), currentDate.getDate())
if (isHolidayDate(key)) {
return true
}
}
return false
}
const handleSubmit = async () => {
if (isSubmitting.value) return
@@ -511,6 +437,10 @@ const handleSubmit = async () => {
try {
const start = normalizeDate(form.startDate)
const end = normalizeDate(form.endDate)
if (hasHolidayInRange(start, end)) {
window.alert("Impossible de creer une absence sur un jour ferie.")
return
}
const overlaps = absences.value.filter((absence) => {
if (absence.employee?.id !== Number(form.employeeId)) return false
if (editingAbsence.value && absence.id === editingAbsence.value.id) return false
@@ -519,8 +449,15 @@ const handleSubmit = async () => {
return start <= aEnd && end >= aStart
})
for (const overlap of overlaps) {
await deleteAbsence(overlap.id)
if (overlaps.length > 0) {
// Securise le chevauchement: on demande confirmation avant suppression.
const confirmReplace = window.confirm(
"Cette absence chevauche une autre. Voulez-vous la remplacer ?"
)
if (!confirmReplace) return
for (const overlap of overlaps) {
await deleteAbsence(overlap.id)
}
}
if (editingAbsence.value) {
@@ -552,8 +489,8 @@ const handleSubmit = async () => {
const handleDelete = async () => {
if (!editingAbsence.value) return
const ok = window.confirm('Supprimer cette absence ?')
if (!ok) return
const confirmDelete = window.confirm('Supprimer cette absence ?')
if (!confirmDelete) return
await deleteAbsence(editingAbsence.value.id)
closeDrawer()

View File

@@ -60,35 +60,50 @@
<AppDrawer v-model="isDrawerOpen" :title="drawerTitle">
<form class="space-y-4" @submit.prevent="handleSubmit">
<div>
<label class="text-md font-semibold text-neutral-700" for="first-name">Prénom</label>
<label class="text-md font-semibold text-neutral-700" for="first-name">
Prénom <span class="text-red-600">*</span>
</label>
<input
id="first-name"
v-model="form.firstName"
type="text"
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200"
:class="firstNameFieldClass"
/>
<p v-if="showFirstNameError" class="mt-1 text-sm text-red-600">
Le prénom est obligatoire.
</p>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="last-name">Nom</label>
<label class="text-md font-semibold text-neutral-700" for="last-name">
Nom <span class="text-red-600">*</span>
</label>
<input
id="last-name"
v-model="form.lastName"
type="text"
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200"
:class="lastNameFieldClass"
/>
<p v-if="showLastNameError" class="mt-1 text-sm text-red-600">
Le nom est obligatoire.
</p>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="site">Site</label>
<label class="text-md font-semibold text-neutral-700" for="site">
Site <span class="text-red-600">*</span>
</label>
<select
id="site"
v-model="form.siteId"
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900"
:class="siteFieldClass"
>
<option value="">Aucun site</option>
<option v-for="site in sites" :key="site.id" :value="site.id">
{{ site.name }}
</option>
</select>
<p v-if="showSiteError" class="mt-1 text-sm text-red-600">
Le site est obligatoire.
</p>
</div>
<div class="flex justify-end gap-3 pt-2">
<button
@@ -101,7 +116,7 @@
<button
type="submit"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
:disabled="isSubmitting"
:class="submitButtonClass"
>
Enregistrer
</button>
@@ -134,6 +149,59 @@ const form = reactive({
siteId: '' as number | ''
})
const validationTouched = reactive({
firstName: false,
lastName: false,
siteId: false
})
const isFirstNameValid = computed(() => form.firstName.trim() !== '')
const isLastNameValid = computed(() => form.lastName.trim() !== '')
const isSiteValid = computed(() => form.siteId !== '')
const isFormValid = computed(
() => isFirstNameValid.value && isLastNameValid.value && isSiteValid.value
)
const showFirstNameError = computed(
() => validationTouched.firstName && !isFirstNameValid.value
)
const showLastNameError = computed(
() => validationTouched.lastName && !isLastNameValid.value
)
const showSiteError = computed(
() => validationTouched.siteId && !isSiteValid.value
)
const baseInputClass =
'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200'
const firstNameFieldClass = computed(() => {
if (showFirstNameError.value) {
return `${baseInputClass} border-red-500`
}
return `${baseInputClass} border-neutral-300`
})
const lastNameFieldClass = computed(() => {
if (showLastNameError.value) {
return `${baseInputClass} border-red-500`
}
return `${baseInputClass} border-neutral-300`
})
const siteFieldClass = computed(() => {
const baseSelectClass =
'mt-2 w-full rounded-md border bg-white px-3 py-2 text-md text-neutral-900'
if (showSiteError.value) {
return `${baseSelectClass} border-red-500`
}
return `${baseSelectClass} border-neutral-300`
})
const submitButtonClass = computed(() => {
if (isSubmitting.value || !isFormValid.value) {
return 'opacity-50 cursor-not-allowed'
}
return ''
})
const loadEmployees = async () => {
isLoading.value = true
try {
@@ -153,6 +221,10 @@ onMounted(async () => {
const handleSubmit = async () => {
if (isSubmitting.value) return
validationTouched.firstName = true
validationTouched.lastName = true
validationTouched.siteId = true
if (!isFormValid.value) return
isSubmitting.value = true
try {
@@ -181,6 +253,14 @@ const handleSubmit = async () => {
}
}
watch(isDrawerOpen, (isOpen) => {
if (!isOpen) {
validationTouched.firstName = false
validationTouched.lastName = false
validationTouched.siteId = false
}
})
const openEdit = (employee: Employee) => {
editingEmployee.value = employee
form.firstName = employee.firstName

View File

@@ -3,7 +3,7 @@
<span
class="flex items-center justify-center bg-white text-xl font-bold uppercase text-primary-500 p-4"
>
LOGO
<img src="/malio.png" alt="Logo" class="w-[150px]"/>
</span>
<form
class="mt-8 space-y-6 rounded-lg border border-neutral-200 bg-white p-6 shadow-sm"

View File

@@ -64,16 +64,23 @@
<AppDrawer v-model="isDrawerOpen" :title="drawerTitle">
<form class="space-y-4" @submit.prevent="handleSubmit">
<div>
<label class="text-md font-semibold text-neutral-700" for="name">Nom</label>
<label class="text-md font-semibold text-neutral-700" for="name">
Nom <span class="text-red-600">*</span>
</label>
<input
id="name"
v-model="form.name"
type="text"
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200"
:class="nameFieldClass"
/>
<p v-if="showNameError" class="mt-1 text-sm text-red-600">
Le nom du site est obligatoire.
</p>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="color">Couleur</label>
<label class="text-md font-semibold text-neutral-700" for="color">
Couleur <span class="text-red-600">*</span>
</label>
<div class="mt-2 flex items-center gap-3">
<input
id="color"
@@ -95,7 +102,7 @@
<button
type="submit"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
:disabled="isSubmitting"
:class="submitButtonClass"
>
Enregistrer
</button>
@@ -125,6 +132,31 @@ const form = reactive({
color: '#222783'
})
const validationTouched = reactive({
name: false
})
const isNameValid = computed(() => form.name.trim() !== '')
const isFormValid = computed(() => isNameValid.value)
const showNameError = computed(() => validationTouched.name && !isNameValid.value)
const baseInputClass =
'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200'
const nameFieldClass = computed(() => {
if (showNameError.value) {
return `${baseInputClass} border-red-500`
}
return `${baseInputClass} border-neutral-300`
})
const submitButtonClass = computed(() => {
if (isSubmitting.value || !isFormValid.value) {
return 'opacity-50 cursor-not-allowed'
}
return ''
})
const loadSites = async () => {
isLoading.value = true
try {
@@ -162,6 +194,8 @@ const closeDrawer = () => {
const handleSubmit = async () => {
if (isSubmitting.value) return
validationTouched.name = true
if (!isFormValid.value) return
isSubmitting.value = true
try {
@@ -184,6 +218,12 @@ const handleSubmit = async () => {
}
}
watch(isDrawerOpen, (isOpen) => {
if (!isOpen) {
validationTouched.name = false
}
})
const confirmDelete = async (site: Site) => {
const ok = window.confirm(`Supprimer le site ${site.name} ?`)
if (!ok) return

View File

@@ -1,11 +1,27 @@
import type { Absence } from './dto/absence'
import { extractItems } from '~/utils/api'
export const listAbsences = async () => {
type ListAbsencesFilters = {
from?: string
to?: string
siteIds?: number[]
}
export const listAbsences = async (filters: ListAbsencesFilters = {}) => {
const api = useApi()
const query: Record<string, string | string[]> = {}
if (filters.from) {
query['endDate[after]'] = filters.from
}
if (filters.to) {
query['startDate[before]'] = filters.to
}
if (filters.siteIds && filters.siteIds.length > 0) {
query['employee.site[]'] = filters.siteIds.map((id) => `/api/sites/${id}`)
}
const data = await api.get<Absence[] | { 'hydra:member'?: Absence[] }>(
'/absences',
{},
query,
{ toast: false }
)
return extractItems<Absence>(data)

View File

@@ -4,5 +4,5 @@ export type Employee = {
id: number
firstName: string
lastName: string
site?: Site | null
site: Site
}

View File

@@ -0,0 +1,18 @@
export type PublicHolidaysResponse =
| { days?: Record<string, string> }
| Record<string, string>
export const listPublicHolidays = async (zone: string, year: number) => {
const api = useApi()
const data = await api.get<PublicHolidaysResponse>(
`/public-holidays/${zone}/${year}`,
{},
{ toast: false }
)
if (data && typeof data === 'object' && 'days' in data) {
return (data.days ?? {}) as Record<string, string>
}
return (data ?? {}) as Record<string, string>
}

View File

@@ -4,6 +4,9 @@ declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use DateTimeInterface;
@@ -19,6 +22,8 @@ use Symfony\Component\Serializer\Attribute\Groups;
'datetime_format' => 'Y-m-d',
]
)]
#[ApiFilter(DateFilter::class, properties: ['startDate', 'endDate'])]
#[ApiFilter(SearchFilter::class, properties: ['employee.site' => 'exact'])]
#[ORM\Entity]
#[ORM\Table(name: 'absences')]
class Absence

View File

@@ -8,6 +8,7 @@ use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Entity\Absence;
use App\Entity\Employee;
use App\Service\PublicHolidayServiceInterface;
use DateInterval;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
@@ -15,6 +16,7 @@ use Dompdf\Dompdf;
use Dompdf\Options;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Throwable;
use Twig\Environment;
use Twig\Error\LoaderError;
use Twig\Error\RuntimeError;
@@ -26,6 +28,7 @@ class AbsencePrintProvider implements ProviderInterface
private Environment $twig,
private readonly RequestStack $requestStack,
private EntityManagerInterface $entityManager,
private PublicHolidayServiceInterface $publicHolidayService,
) {}
/**
@@ -56,6 +59,7 @@ class AbsencePrintProvider implements ProviderInterface
$days = $this->buildDays($fromDate, $toDate);
$absenceMap = $this->buildAbsenceMap($absences, $fromDate, $toDate);
$holidayMap = $this->buildHolidayMap($fromDate, $toDate);
$options = new Options();
$options->set('isRemoteEnabled', true);
@@ -67,6 +71,7 @@ class AbsencePrintProvider implements ProviderInterface
'days' => $days,
'employees' => $employees,
'absenceMap' => $absenceMap,
'holidayMap' => $holidayMap,
]);
$dompdf->loadHtml($html);
@@ -116,7 +121,7 @@ class AbsencePrintProvider implements ProviderInterface
;
}
/** @var list<Employee> $result */
// @var list<Employee> $result
return $qb->getQuery()->getResult();
}
@@ -140,7 +145,7 @@ class AbsencePrintProvider implements ProviderInterface
->setParameter('employees', $employees)
;
/** @var list<Absence> $result */
// @var list<Absence> $result
return $qb->getQuery()->getResult();
}
@@ -194,4 +199,24 @@ class AbsencePrintProvider implements ProviderInterface
return $map;
}
private function buildHolidayMap(DateTimeImmutable $from, DateTimeImmutable $to): array
{
$map = [];
$startYear = (int) $from->format('Y');
$endYear = (int) $to->format('Y');
try {
for ($year = $startYear; $year <= $endYear; ++$year) {
$holidays = $this->publicHolidayService->getHolidaysDayByYears('metropole', (string) $year);
foreach ($holidays as $date => $label) {
$map[$date] = (string) $label;
}
}
} catch (Throwable) {
return [];
}
return $map;
}
}

View File

@@ -0,0 +1,219 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<title>Absences</title>
<style>
@page { size: A3 landscape; margin: 8mm; }
html, body {
margin: 0;
padding: 2mm;
font-family: Helvetica, sans-serif;
font-size: 8px;
}
table.calendar {
width: 100%;
border-collapse: collapse;
table-layout: auto;
border: 4px solid #0a0a0a;
}
th, td {
border: 2px solid #0a0a0a;
padding: 2px 1px;
vertical-align: middle;
overflow: hidden;
white-space: nowrap;
}
thead th {
text-align: center;
font-weight: 700;
}
.col-employee {
font-size: 16px;
font-weight: bold;
width: 10mm;
text-align: left;
}
.col-day {
font-size: 10px;
text-align: center;
}
.month {
font-size: 16px;
font-weight: bold;
padding: 3px 0;
}
.site-title td {
font-weight: bold;
font-size: 16px;
text-transform: uppercase;
border-color: #0a0a0a;
padding: 4px 6px;
}
.site-title .label {
text-align: left;
}
.code {
font-weight: bold;
font-size: 9px;
}
.month-separator {
border-right: 4px solid #0a0a0a !important;
}
.weekend {
background: grey;
}
.holiday {
background: #b3e5fc;
}
</style>
</head>
<body>
{% set months = {
1:'Janvier', 2:'Février', 3:'Mars', 4:'Avril', 5:'Mai', 6:'Juin',
7:'Juillet', 8:'Août', 9:'Septembre', 10:'Octobre', 11:'Novembre', 12:'Décembre'
} %}
{% set dow = ['Lu','Ma','Me','Je','Ve','Sa','Di'] %}
{% set dayColWidthMm = 5 %}
<table class="calendar">
<thead>
{# Ligne 1 : mois #}
<tr>
<th class="col-employee" rowspan="4"></th>
{% set currentMonth = '' %}
{% set monthCount = 0 %}
{% for day in days %}
{% set m = day.date|date('n') %}
{% set monthLabel = months[m] ~ ' ' ~ (day.date|date('Y')) %}
{% if monthLabel != currentMonth %}
{% if not loop.first %}
<th class="month month-separator" colspan="{{ monthCount }}">{{ currentMonth }}</th>
{% endif %}
{% set currentMonth = monthLabel %}
{% set monthCount = 1 %}
{% else %}
{% set monthCount = monthCount + 1 %}
{% endif %}
{% if loop.last %}
<th class="month" colspan="{{ monthCount }}">{{ currentMonth }}</th>
{% endif %}
{% endfor %}
</tr>
{# Ligne 2 : numéro de semaine #}
<tr>
{% set currentWeek = '' %}
{% set weekCount = 0 %}
{% for day in days %}
{% set weekLabel = 'S' ~ (day.date|date('W')) %}
{% if weekLabel != currentWeek %}
{% if not loop.first %}
<th class="col-day" colspan="{{ weekCount }}">{{ currentWeek }}</th>
{% endif %}
{% set currentWeek = weekLabel %}
{% set weekCount = 1 %}
{% else %}
{% set weekCount = weekCount + 1 %}
{% endif %}
{% if loop.last %}
<th class="col-day" colspan="{{ weekCount }}">{{ currentWeek }}</th>
{% endif %}
{% endfor %}
</tr>
{# Ligne 3 : jour semaine #}
<tr>
{% for day in days %}
{% set idx = (day.date|date('N') - 1) %}
{% set isMonthEnd = (not loop.last) and (days[loop.index].date|date('n') != day.date|date('n')) %}
{% set isWeekend = day.date|date('N') in [6, 7] %}
<th class="col-day{% if isMonthEnd %} month-separator{% endif %}{% if isWeekend %} weekend{% endif %}" style="width: {{ dayColWidthMm }}mm;">{{ dow[idx] }}</th>
{% endfor %}
</tr>
{# Ligne 4 : numéro #}
<tr>
{% for day in days %}
{% set isMonthEnd = (not loop.last) and (days[loop.index].date|date('n') != day.date|date('n')) %}
{% set isWeekend = day.date|date('N') in [6, 7] %}
<th class="col-day{% if isMonthEnd %} month-separator{% endif %}{% if isWeekend %} weekend{% endif %}" style="width: {{ dayColWidthMm }}mm;">{{ day.label }}</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% set currentSiteId = null %}
{% for employee in employees %}
{% set site = employee.site %}
{% set siteId = site ? site.id : 'none' %}
{% set siteName = site ? site.name : 'Sans site' %}
{# couleur de fond du site (si tu as un champ color) #}
{% set siteColor = '#ffd7d7' %}
{% if site and attribute(site, 'color') is defined and site.color %}
{% set siteColor = site.color %}
{% endif %}
{# Nouvelle section site #}
{% if siteId != currentSiteId %}
<tr class="site-title">
<td class="label" style="background: {{ siteColor }};" colspan="{{ 1 + (days|length) }}">
{{ siteName }}
</td>
</tr>
{% set currentSiteId = siteId %}
{% endif %}
{# Ligne employé #}
<tr>
<td class="col-employee">
{{ employee.firstName }}{% if employee.lastName %} {{ employee.lastName|first }}.{% endif %}
</td>
{% for day in days %}
{% set isHoliday = holidayMap[day.date] ?? null %}
{% set info = absenceMap[employee.id][day.date] ?? null %}
{% set isMonthEnd = (not loop.last) and (days[loop.index].date|date('n') != day.date|date('n')) %}
{% set isWeekend = day.date|date('N') in [6, 7] %}
<td class="col-day{% if isMonthEnd %} month-separator{% endif %}{% if isWeekend %} weekend{% endif %}{% if isHoliday %} holiday{% endif %}" style="width: {{ dayColWidthMm }}mm;{% if info and not isHoliday %} background-color: {{ info.color }};{% endif %}">
{% if isHoliday %}
<span class="code">F</span>
{% elseif info %}
<span class="code">{{ info.code }}</span>
{% endif %}
</td>
{% endfor %}
</tr>
{% else %}
<tr>
<td colspan="{{ 1 + (days|length) }}">Aucun employé.</td>
</tr>
{% endfor %}
</tbody>
</table>
</body>
</html>