Compare commits
2 Commits
1cfbfb0120
..
v0.1.8
| Author | SHA1 | Date | |
|---|---|---|---|
| f6c1f7eead | |||
| 69e8d74f4d |
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.7'
|
app.version: '0.1.8'
|
||||||
|
|||||||
@@ -30,12 +30,12 @@
|
|||||||
<div
|
<div
|
||||||
v-for="day in daysInMonth"
|
v-for="day in daysInMonth"
|
||||||
:key="employee.id + '-' + day.date"
|
:key="employee.id + '-' + day.date"
|
||||||
class="border-b border-neutral-100 px-2 py-2 text-center text-xs text-neutral-800"
|
class="border-b border-neutral-300 px-2 py-2 text-center text-xs text-neutral-800 hover:bg-neutral-500"
|
||||||
>
|
>
|
||||||
<template v-if="getCellInfo(employee.id, day.date)">
|
<template v-if="getCellInfo(employee.id, day.date)">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="relative flex h-8 w-full items-center justify-center overflow-hidden rounded-md border border-neutral-200 text-[11px] font-semibold text-neutral-800 hover:border-primary-500/40"
|
class="relative flex h-8 w-full items-center justify-center overflow-hidden rounded-md border border-neutral-200 text-[11px] font-semibold text-neutral-800 hover:border-white"
|
||||||
:class="isHolidayDate(day.date) ? 'cursor-not-allowed opacity-80' : ''"
|
:class="isHolidayDate(day.date) ? 'cursor-not-allowed opacity-80' : ''"
|
||||||
:style="getCellStyle(employee.id, day.date)"
|
:style="getCellStyle(employee.id, day.date)"
|
||||||
:disabled="isHolidayDate(day.date)"
|
:disabled="isHolidayDate(day.date)"
|
||||||
@@ -63,7 +63,7 @@
|
|||||||
<template v-else>
|
<template v-else>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="relative 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="relative flex h-8 w-full items-center justify-center rounded-md border border-neutral-200 text-[11px] font-semibold text-neutral-800 bg-white hover:border-white"
|
||||||
:class="isHolidayDate(day.date) ? 'cursor-not-allowed opacity-80' : ''"
|
:class="isHolidayDate(day.date) ? 'cursor-not-allowed opacity-80' : ''"
|
||||||
:style="getCellStyle(employee.id, day.date)"
|
:style="getCellStyle(employee.id, day.date)"
|
||||||
:disabled="isHolidayDate(day.date)"
|
:disabled="isHolidayDate(day.date)"
|
||||||
|
|||||||
@@ -35,13 +35,6 @@
|
|||||||
>
|
>
|
||||||
Sites
|
Sites
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink
|
|
||||||
to="/users"
|
|
||||||
class="flex items-center gap-3 px-4 py-3 text-md font-semibold text-neutral-700 hover:bg-primary-50 hover:text-primary-600"
|
|
||||||
active-class="bg-primary-50 text-primary-600"
|
|
||||||
>
|
|
||||||
Utilisateurs
|
|
||||||
</NuxtLink>
|
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/absence-types"
|
to="/absence-types"
|
||||||
class="flex items-center gap-3 px-4 py-3 text-md font-semibold text-neutral-700 hover:bg-primary-50 hover:text-primary-600"
|
class="flex items-center gap-3 px-4 py-3 text-md font-semibold text-neutral-700 hover:bg-primary-50 hover:text-primary-600"
|
||||||
@@ -49,6 +42,13 @@
|
|||||||
>
|
>
|
||||||
Types d'absence
|
Types d'absence
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
<NuxtLink
|
||||||
|
to="/users"
|
||||||
|
class="flex items-center gap-3 px-4 py-3 text-md font-semibold text-neutral-700 hover:bg-primary-50 hover:text-primary-600"
|
||||||
|
active-class="bg-primary-50 text-primary-600"
|
||||||
|
>
|
||||||
|
Utilisateurs
|
||||||
|
</NuxtLink>
|
||||||
</template>
|
</template>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|||||||
@@ -53,14 +53,24 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-3 flex items-center gap-4">
|
<div class="flex justify-between mt-3">
|
||||||
<input
|
<div class="flex items-center gap-4 w-80">
|
||||||
v-model="employeeFilter"
|
<input
|
||||||
type="text"
|
v-model="employeeFilter"
|
||||||
placeholder="Chercher un employé (nom ou prénom)"
|
type="text"
|
||||||
class="h-10 w-full max-w-md rounded-md border border-neutral-300 bg-white px-3 text-md text-neutral-900"
|
placeholder="Chercher un employé (nom ou prénom)"
|
||||||
/>
|
class="h-10 w-full max-w-md rounded-md border border-neutral-300 bg-white px-3 text-md text-neutral-900"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap items-center gap-4 rounded-md border border-neutral-300 px-3 py-2">
|
||||||
|
<p class="font-bold">Légende :</p>
|
||||||
|
<div v-for="type in absenceTypes" :key="type.id" class="flex items-center gap-2">
|
||||||
|
<div :style="{ backgroundColor: type.color }" class="h-4 w-4 rounded"></div>
|
||||||
|
<p>{{ type.label }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 min-h-0">
|
<div class="flex-1 min-h-0">
|
||||||
@@ -123,7 +133,12 @@ const sites = computed(() => {
|
|||||||
siteMap.set(employee.site.id, employee.site)
|
siteMap.set(employee.site.id, employee.site)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Array.from(siteMap.values()).sort((siteA, siteB) => siteA.name.localeCompare(siteB.name, 'fr'))
|
return Array.from(siteMap.values()).sort((siteA, siteB) => {
|
||||||
|
const orderA = siteA.displayOrder ?? 0
|
||||||
|
const orderB = siteB.displayOrder ?? 0
|
||||||
|
if (orderA !== orderB) return orderA - orderB
|
||||||
|
return siteA.name.localeCompare(siteB.name, 'fr')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Filtres de sites (par défaut: tous sélectionnés à l'init).
|
// Filtres de sites (par défaut: tous sélectionnés à l'init).
|
||||||
@@ -139,6 +154,9 @@ watch(sites, (next) => {
|
|||||||
// Tri stable: site -> nom -> prénom.
|
// Tri stable: site -> nom -> prénom.
|
||||||
const sortedEmployees = computed(() => {
|
const sortedEmployees = computed(() => {
|
||||||
return [...employees.value].sort((employeeA, employeeB) => {
|
return [...employees.value].sort((employeeA, employeeB) => {
|
||||||
|
const siteOrderA = employeeA.site?.displayOrder ?? 0
|
||||||
|
const siteOrderB = employeeB.site?.displayOrder ?? 0
|
||||||
|
if (siteOrderA !== siteOrderB) return siteOrderA - siteOrderB
|
||||||
const siteNameA = employeeA.site?.name ?? ''
|
const siteNameA = employeeA.site?.name ?? ''
|
||||||
const siteNameB = employeeB.site?.name ?? ''
|
const siteNameB = employeeB.site?.name ?? ''
|
||||||
if (siteNameA !== siteNameB) return siteNameA.localeCompare(siteNameB, 'fr')
|
if (siteNameA !== siteNameB) return siteNameA.localeCompare(siteNameB, 'fr')
|
||||||
|
|||||||
@@ -32,8 +32,15 @@
|
|||||||
v-for="site in sites"
|
v-for="site in sites"
|
||||||
:key="site.id"
|
:key="site.id"
|
||||||
class="grid grid-cols-[1fr_140px_160px] items-center gap-4 border-b border-neutral-100 px-6 py-3 text-md text-neutral-800 last:border-b-0"
|
class="grid grid-cols-[1fr_140px_160px] items-center gap-4 border-b border-neutral-100 px-6 py-3 text-md text-neutral-800 last:border-b-0"
|
||||||
|
draggable="true"
|
||||||
|
@dragstart="handleDragStart($event, site)"
|
||||||
|
@dragover="handleDragOver"
|
||||||
|
@drop="handleDrop($event, site)"
|
||||||
>
|
>
|
||||||
<span class="text-left">{{ site.name }}</span>
|
<span class="flex items-center gap-2 text-left cursor-pointer">
|
||||||
|
<span class="select-none text-xs">::</span>
|
||||||
|
<span>{{ site.name }}</span>
|
||||||
|
</span>
|
||||||
<div class="flex items-center gap-2 justify-start">
|
<div class="flex items-center gap-2 justify-start">
|
||||||
<span
|
<span
|
||||||
class="inline-block h-3 w-3 rounded-full"
|
class="inline-block h-3 w-3 rounded-full"
|
||||||
@@ -114,11 +121,12 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Site } from '~/services/dto/site'
|
import type { Site } from '~/services/dto/site'
|
||||||
import { createSite, deleteSite, listSites, updateSite } from '~/services/sites'
|
import { createSite, deleteSite, listSites, updateSite, updateSiteOrder } from '~/services/sites'
|
||||||
|
|
||||||
const isDrawerOpen = ref(false)
|
const isDrawerOpen = ref(false)
|
||||||
const isSubmitting = ref(false)
|
const isSubmitting = ref(false)
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
|
const isReordering = ref(false)
|
||||||
|
|
||||||
const sites = ref<Site[]>([])
|
const sites = ref<Site[]>([])
|
||||||
const editingSite = ref<Site | null>(null)
|
const editingSite = ref<Site | null>(null)
|
||||||
@@ -207,7 +215,8 @@ const handleSubmit = async () => {
|
|||||||
} else {
|
} else {
|
||||||
await createSite({
|
await createSite({
|
||||||
name: form.name,
|
name: form.name,
|
||||||
color: form.color
|
color: form.color,
|
||||||
|
displayOrder: sites.value.length + 1
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,4 +240,52 @@ const confirmDelete = async (site: Site) => {
|
|||||||
await deleteSite(site.id)
|
await deleteSite(site.id)
|
||||||
await loadSites()
|
await loadSites()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleDragStart = (event: DragEvent, site: Site) => {
|
||||||
|
if (isReordering.value || !event.dataTransfer) return
|
||||||
|
event.dataTransfer.effectAllowed = 'move'
|
||||||
|
event.dataTransfer.setData('text/plain', String(site.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragOver = (event: DragEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDrop = async (event: DragEvent, site: Site) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (isReordering.value) return
|
||||||
|
|
||||||
|
const dragId = Number(event.dataTransfer?.getData('text/plain'))
|
||||||
|
if (!dragId || dragId === site.id) return
|
||||||
|
|
||||||
|
const fromIndex = sites.value.findIndex((item) => item.id === dragId)
|
||||||
|
const toIndex = sites.value.findIndex((item) => item.id === site.id)
|
||||||
|
if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) return
|
||||||
|
|
||||||
|
const reordered = [...sites.value]
|
||||||
|
const [moved] = reordered.splice(fromIndex, 1)
|
||||||
|
reordered.splice(toIndex, 0, moved)
|
||||||
|
|
||||||
|
const updates: Array<{ id: number; displayOrder: number }> = []
|
||||||
|
reordered.forEach((item, index) => {
|
||||||
|
const nextOrder = index + 1
|
||||||
|
if ((item.displayOrder ?? 0) !== nextOrder) {
|
||||||
|
updates.push({ id: item.id, displayOrder: nextOrder })
|
||||||
|
}
|
||||||
|
item.displayOrder = nextOrder
|
||||||
|
})
|
||||||
|
|
||||||
|
sites.value = reordered
|
||||||
|
if (updates.length === 0) return
|
||||||
|
|
||||||
|
isReordering.value = true
|
||||||
|
try {
|
||||||
|
await Promise.all(updates.map((update) => updateSiteOrder(update.id, update.displayOrder)))
|
||||||
|
} catch {
|
||||||
|
window.alert("Impossible de reordonner les sites.")
|
||||||
|
await loadSites()
|
||||||
|
} finally {
|
||||||
|
isReordering.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -2,4 +2,5 @@ export type Site = {
|
|||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
color: string
|
color: string
|
||||||
|
displayOrder?: number
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,15 @@ export const listSites = async () => {
|
|||||||
{},
|
{},
|
||||||
{ toast: false }
|
{ toast: false }
|
||||||
)
|
)
|
||||||
return extractItems<Site>(data)
|
return extractItems<Site>(data).sort((siteA, siteB) => {
|
||||||
|
const orderA = siteA.displayOrder ?? 0
|
||||||
|
const orderB = siteB.displayOrder ?? 0
|
||||||
|
if (orderA !== orderB) return orderA - orderB
|
||||||
|
return siteA.name.localeCompare(siteB.name, 'fr')
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createSite = async (payload: Pick<Site, 'name' | 'color'>) => {
|
export const createSite = async (payload: Pick<Site, 'name' | 'color'> & { displayOrder?: number }) => {
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
return api.post<Site>('/sites', payload, {
|
return api.post<Site>('/sites', payload, {
|
||||||
toastSuccessKey: 'success.site.create',
|
toastSuccessKey: 'success.site.create',
|
||||||
@@ -19,7 +24,10 @@ export const createSite = async (payload: Pick<Site, 'name' | 'color'>) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const updateSite = async (id: number, payload: Pick<Site, 'name' | 'color'>) => {
|
export const updateSite = async (
|
||||||
|
id: number,
|
||||||
|
payload: Pick<Site, 'name' | 'color'> & { displayOrder?: number }
|
||||||
|
) => {
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
return api.patch<Site>(`/sites/${id}`, payload, {
|
return api.patch<Site>(`/sites/${id}`, payload, {
|
||||||
toastSuccessKey: 'success.site.update',
|
toastSuccessKey: 'success.site.update',
|
||||||
@@ -27,6 +35,15 @@ export const updateSite = async (id: number, payload: Pick<Site, 'name' | 'color
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const updateSiteOrder = async (id: number, displayOrder: number) => {
|
||||||
|
const api = useApi()
|
||||||
|
return api.patch<Site>(`/sites/${id}`, {
|
||||||
|
displayOrder
|
||||||
|
}, {
|
||||||
|
toast: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export const deleteSite = async (id: number) => {
|
export const deleteSite = async (id: number) => {
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
return api.delete(`/sites/${id}`, {}, {
|
return api.delete(`/sites/${id}`, {}, {
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace DoctrineMigrations;
|
|
||||||
|
|
||||||
use Doctrine\DBAL\Schema\Schema;
|
|
||||||
use Doctrine\Migrations\AbstractMigration;
|
|
||||||
|
|
||||||
final class Version20260216100000 extends AbstractMigration
|
|
||||||
{
|
|
||||||
public function getDescription(): string
|
|
||||||
{
|
|
||||||
return 'Create work_hours table';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function up(Schema $schema): void
|
|
||||||
{
|
|
||||||
$this->addSql('CREATE TABLE work_hours (id SERIAL NOT NULL, employee_id INT NOT NULL, work_date DATE NOT NULL, morning_from VARCHAR(5) DEFAULT NULL, morning_to VARCHAR(5) DEFAULT NULL, afternoon_from VARCHAR(5) DEFAULT NULL, afternoon_to VARCHAR(5) DEFAULT NULL, evening_from VARCHAR(5) DEFAULT NULL, evening_to VARCHAR(5) DEFAULT NULL, PRIMARY KEY(id))');
|
|
||||||
$this->addSql('CREATE INDEX IDX_WORK_HOURS_EMPLOYEE ON work_hours (employee_id)');
|
|
||||||
$this->addSql('CREATE INDEX IDX_WORK_HOURS_DATE ON work_hours (work_date)');
|
|
||||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_WORK_HOURS_EMPLOYEE_DATE ON work_hours (employee_id, work_date)');
|
|
||||||
$this->addSql('ALTER TABLE work_hours ADD CONSTRAINT FK_WORK_HOURS_EMPLOYEE FOREIGN KEY (employee_id) REFERENCES employees (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(Schema $schema): void
|
|
||||||
{
|
|
||||||
$this->addSql('ALTER TABLE work_hours DROP CONSTRAINT FK_WORK_HOURS_EMPLOYEE');
|
|
||||||
$this->addSql('DROP TABLE work_hours');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260216143000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add display_order to sites';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE sites ADD display_order INT NOT NULL DEFAULT 0');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE sites DROP COLUMN display_order');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260216143100 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Backfill site display_order';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('
|
||||||
|
UPDATE sites s
|
||||||
|
SET display_order = ranked.rn
|
||||||
|
FROM (
|
||||||
|
SELECT id,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
ORDER BY name ASC, id ASC
|
||||||
|
) AS rn
|
||||||
|
FROM sites
|
||||||
|
) ranked
|
||||||
|
WHERE ranked.id = s.id
|
||||||
|
');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// Pas de rollback pertinent.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\ApiResource;
|
|
||||||
|
|
||||||
use ApiPlatform\Metadata\ApiResource;
|
|
||||||
use ApiPlatform\Metadata\Post;
|
|
||||||
use App\State\WorkHourBulkUpsertProcessor;
|
|
||||||
|
|
||||||
#[ApiResource(
|
|
||||||
operations: [
|
|
||||||
new Post(
|
|
||||||
uriTemplate: '/work-hours/bulk-upsert',
|
|
||||||
security: "is_granted('ROLE_USER')",
|
|
||||||
output: WorkHourBulkUpsertResult::class,
|
|
||||||
processor: WorkHourBulkUpsertProcessor::class
|
|
||||||
),
|
|
||||||
]
|
|
||||||
)]
|
|
||||||
final class WorkHourBulkUpsert
|
|
||||||
{
|
|
||||||
public string $workDate = '';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var list<array{
|
|
||||||
* employeeId:int,
|
|
||||||
* morningFrom?:?string,
|
|
||||||
* morningTo?:?string,
|
|
||||||
* afternoonFrom?:?string,
|
|
||||||
* afternoonTo?:?string,
|
|
||||||
* eveningFrom?:?string,
|
|
||||||
* eveningTo?:?string
|
|
||||||
* }>
|
|
||||||
*/
|
|
||||||
public array $entries = [];
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\ApiResource;
|
|
||||||
|
|
||||||
final class WorkHourBulkUpsertResult
|
|
||||||
{
|
|
||||||
public int $processed = 0;
|
|
||||||
public int $created = 0;
|
|
||||||
public int $updated = 0;
|
|
||||||
public int $deleted = 0;
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Doctrine;
|
|
||||||
|
|
||||||
use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
|
|
||||||
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
|
|
||||||
use ApiPlatform\Metadata\Operation;
|
|
||||||
use App\Entity\User;
|
|
||||||
use App\Entity\WorkHour;
|
|
||||||
use App\Security\EmployeeScopeService;
|
|
||||||
use Doctrine\ORM\QueryBuilder;
|
|
||||||
use Symfony\Bundle\SecurityBundle\Security;
|
|
||||||
|
|
||||||
final readonly class WorkHourCollectionExtension implements QueryCollectionExtensionInterface
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private Security $security,
|
|
||||||
private EmployeeScopeService $employeeScopeService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function applyToCollection(
|
|
||||||
QueryBuilder $queryBuilder,
|
|
||||||
QueryNameGeneratorInterface $queryNameGenerator,
|
|
||||||
string $resourceClass,
|
|
||||||
?Operation $operation = null,
|
|
||||||
array $context = []
|
|
||||||
): void {
|
|
||||||
// N'applique le filtrage qu'à la ressource WorkHour.
|
|
||||||
if (WorkHour::class !== $resourceClass) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = $this->security->getUser();
|
|
||||||
if (!$user instanceof User) {
|
|
||||||
// Pas d'utilisateur => aucune ligne renvoyée.
|
|
||||||
$queryBuilder->andWhere('1 = 0');
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$rootAlias = $queryBuilder->getRootAliases()[0];
|
|
||||||
$employeeAlias = 'employee_scope';
|
|
||||||
|
|
||||||
$queryBuilder->leftJoin(sprintf('%s.employee', $rootAlias), $employeeAlias)
|
|
||||||
->addSelect($employeeAlias)
|
|
||||||
;
|
|
||||||
|
|
||||||
// Filtrage SQL par scope (admin/self/site) avant retour API.
|
|
||||||
$this->employeeScopeService->applyEmployeeScope($queryBuilder, $employeeAlias, 'work_hour_scope', $user);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -31,6 +31,10 @@ class Site
|
|||||||
#[Groups(['site:read', 'employee:read'])]
|
#[Groups(['site:read', 'employee:read'])]
|
||||||
private string $color = '';
|
private string $color = '';
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'integer', options: ['default' => 0])]
|
||||||
|
#[Groups(['site:read'])]
|
||||||
|
private int $displayOrder = 0;
|
||||||
|
|
||||||
public function getId(): ?int
|
public function getId(): ?int
|
||||||
{
|
{
|
||||||
return $this->id;
|
return $this->id;
|
||||||
@@ -59,4 +63,16 @@ class Site
|
|||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getDisplayOrder(): int
|
||||||
|
{
|
||||||
|
return $this->displayOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setDisplayOrder(int $displayOrder): self
|
||||||
|
{
|
||||||
|
$this->displayOrder = $displayOrder;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,178 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
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 ApiPlatform\Metadata\Get;
|
|
||||||
use ApiPlatform\Metadata\GetCollection;
|
|
||||||
use DateTimeInterface;
|
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
|
||||||
use Symfony\Component\Serializer\Attribute\Groups;
|
|
||||||
|
|
||||||
#[ApiResource(
|
|
||||||
operations: [
|
|
||||||
new GetCollection(
|
|
||||||
paginationEnabled: false,
|
|
||||||
normalizationContext: ['groups' => ['work_hour:read', 'employee:read', 'site:read']],
|
|
||||||
security: "is_granted('ROLE_USER')"
|
|
||||||
),
|
|
||||||
new Get(
|
|
||||||
normalizationContext: ['groups' => ['work_hour:read', 'employee:read', 'site:read']],
|
|
||||||
security: "is_granted('WORK_HOUR_VIEW', object)"
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)]
|
|
||||||
#[ApiFilter(DateFilter::class, properties: ['workDate'])]
|
|
||||||
#[ApiFilter(SearchFilter::class, properties: ['employee' => 'exact', 'employee.site' => 'exact'])]
|
|
||||||
#[ORM\Entity]
|
|
||||||
#[ORM\Table(name: 'work_hours')]
|
|
||||||
#[ORM\UniqueConstraint(name: 'uniq_work_hours_employee_date', fields: ['employee', 'workDate'])]
|
|
||||||
class WorkHour
|
|
||||||
{
|
|
||||||
#[ORM\Id]
|
|
||||||
#[ORM\GeneratedValue]
|
|
||||||
#[ORM\Column(type: 'integer')]
|
|
||||||
#[Groups(['work_hour:read'])]
|
|
||||||
private ?int $id = null;
|
|
||||||
|
|
||||||
#[ApiProperty(readableLink: true)]
|
|
||||||
#[ORM\ManyToOne(targetEntity: Employee::class)]
|
|
||||||
#[ORM\JoinColumn(nullable: false)]
|
|
||||||
#[Groups(['work_hour:read'])]
|
|
||||||
private ?Employee $employee = null;
|
|
||||||
|
|
||||||
#[ORM\Column(type: 'date_immutable')]
|
|
||||||
#[Groups(['work_hour:read'])]
|
|
||||||
private DateTimeInterface $workDate;
|
|
||||||
|
|
||||||
#[ORM\Column(type: 'string', length: 5, nullable: true)]
|
|
||||||
#[Groups(['work_hour:read'])]
|
|
||||||
private ?string $morningFrom = null;
|
|
||||||
|
|
||||||
#[ORM\Column(type: 'string', length: 5, nullable: true)]
|
|
||||||
#[Groups(['work_hour:read'])]
|
|
||||||
private ?string $morningTo = null;
|
|
||||||
|
|
||||||
#[ORM\Column(type: 'string', length: 5, nullable: true)]
|
|
||||||
#[Groups(['work_hour:read'])]
|
|
||||||
private ?string $afternoonFrom = null;
|
|
||||||
|
|
||||||
#[ORM\Column(type: 'string', length: 5, nullable: true)]
|
|
||||||
#[Groups(['work_hour:read'])]
|
|
||||||
private ?string $afternoonTo = null;
|
|
||||||
|
|
||||||
#[ORM\Column(type: 'string', length: 5, nullable: true)]
|
|
||||||
#[Groups(['work_hour:read'])]
|
|
||||||
private ?string $eveningFrom = null;
|
|
||||||
|
|
||||||
#[ORM\Column(type: 'string', length: 5, nullable: true)]
|
|
||||||
#[Groups(['work_hour:read'])]
|
|
||||||
private ?string $eveningTo = null;
|
|
||||||
|
|
||||||
public function getId(): ?int
|
|
||||||
{
|
|
||||||
return $this->id;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getEmployee(): ?Employee
|
|
||||||
{
|
|
||||||
return $this->employee;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setEmployee(?Employee $employee): self
|
|
||||||
{
|
|
||||||
$this->employee = $employee;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getWorkDate(): DateTimeInterface
|
|
||||||
{
|
|
||||||
return $this->workDate;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setWorkDate(DateTimeInterface $workDate): self
|
|
||||||
{
|
|
||||||
$this->workDate = $workDate;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getMorningFrom(): ?string
|
|
||||||
{
|
|
||||||
return $this->morningFrom;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setMorningFrom(?string $morningFrom): self
|
|
||||||
{
|
|
||||||
$this->morningFrom = $morningFrom;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getMorningTo(): ?string
|
|
||||||
{
|
|
||||||
return $this->morningTo;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setMorningTo(?string $morningTo): self
|
|
||||||
{
|
|
||||||
$this->morningTo = $morningTo;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getAfternoonFrom(): ?string
|
|
||||||
{
|
|
||||||
return $this->afternoonFrom;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setAfternoonFrom(?string $afternoonFrom): self
|
|
||||||
{
|
|
||||||
$this->afternoonFrom = $afternoonFrom;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getAfternoonTo(): ?string
|
|
||||||
{
|
|
||||||
return $this->afternoonTo;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setAfternoonTo(?string $afternoonTo): self
|
|
||||||
{
|
|
||||||
$this->afternoonTo = $afternoonTo;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getEveningFrom(): ?string
|
|
||||||
{
|
|
||||||
return $this->eveningFrom;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setEveningFrom(?string $eveningFrom): self
|
|
||||||
{
|
|
||||||
$this->eveningFrom = $eveningFrom;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getEveningTo(): ?string
|
|
||||||
{
|
|
||||||
return $this->eveningTo;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setEveningTo(?string $eveningTo): self
|
|
||||||
{
|
|
||||||
$this->eveningTo = $eveningTo;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Security;
|
|
||||||
|
|
||||||
use App\Entity\Employee;
|
|
||||||
use App\Entity\User;
|
|
||||||
use Doctrine\ORM\QueryBuilder;
|
|
||||||
|
|
||||||
class EmployeeScopeService
|
|
||||||
{
|
|
||||||
public const string SITE_ACCESS_ROLE = 'SITE_ACCESS';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Règle métier centrale d'accès à un employé.
|
|
||||||
* - Admin : accès global
|
|
||||||
* - Self : uniquement son employé lié
|
|
||||||
* - Site : uniquement les employés des sites autorisés.
|
|
||||||
*/
|
|
||||||
public function canAccessEmployee(User $user, Employee $employee): bool
|
|
||||||
{
|
|
||||||
if (in_array('ROLE_ADMIN', $user->getRoles(), true)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (in_array('ROLE_SELF', $user->getRoles(), true)) {
|
|
||||||
return $user->getEmployee()?->getId() === $employee->getId();
|
|
||||||
}
|
|
||||||
|
|
||||||
$employeeSiteId = $employee->getSite()?->getId();
|
|
||||||
if (!$employeeSiteId) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return in_array($employeeSiteId, $this->getAllowedSiteIds($user), true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retourne la liste des sites accessibles via user_site_roles.
|
|
||||||
*
|
|
||||||
* @return list<int>
|
|
||||||
*/
|
|
||||||
public function getAllowedSiteIds(User $user): array
|
|
||||||
{
|
|
||||||
$siteIds = [];
|
|
||||||
|
|
||||||
foreach ($user->getSiteRoles() as $siteRole) {
|
|
||||||
if (self::SITE_ACCESS_ROLE !== $siteRole->getRole()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$siteId = $siteRole->getSite()?->getId();
|
|
||||||
if ($siteId) {
|
|
||||||
$siteIds[] = $siteId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return array_values(array_unique($siteIds));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Applique le scope directement sur un QueryBuilder Doctrine.
|
|
||||||
* Cette méthode est utilisée pour filtrer les collections SQL
|
|
||||||
* avant sérialisation (plus sûr et plus performant).
|
|
||||||
*/
|
|
||||||
public function applyEmployeeScope(QueryBuilder $qb, string $employeeAlias, string $paramPrefix, User $user): void
|
|
||||||
{
|
|
||||||
if (in_array('ROLE_ADMIN', $user->getRoles(), true)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (in_array('ROLE_SELF', $user->getRoles(), true)) {
|
|
||||||
$employeeId = $user->getEmployee()?->getId();
|
|
||||||
if (!$employeeId) {
|
|
||||||
$qb->andWhere('1 = 0');
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$qb->andWhere(sprintf('%s.id = :%s_employee_id', $employeeAlias, $paramPrefix))
|
|
||||||
->setParameter(sprintf('%s_employee_id', $paramPrefix), $employeeId)
|
|
||||||
;
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$siteIds = $this->getAllowedSiteIds($user);
|
|
||||||
if ([] === $siteIds) {
|
|
||||||
$qb->andWhere('1 = 0');
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$qb->andWhere(sprintf('%s.site IN (:%s_site_ids)', $employeeAlias, $paramPrefix))
|
|
||||||
->setParameter(sprintf('%s_site_ids', $paramPrefix), $siteIds)
|
|
||||||
;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Security\Voter;
|
|
||||||
|
|
||||||
use App\Entity\User;
|
|
||||||
use App\Entity\WorkHour;
|
|
||||||
use App\Security\EmployeeScopeService;
|
|
||||||
use Symfony\Bundle\SecurityBundle\Security;
|
|
||||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
|
||||||
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
|
|
||||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
|
||||||
|
|
||||||
class WorkHourVoter extends Voter
|
|
||||||
{
|
|
||||||
public const string VIEW = 'WORK_HOUR_VIEW';
|
|
||||||
public const string EDIT = 'WORK_HOUR_EDIT';
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private readonly Security $security,
|
|
||||||
private readonly EmployeeScopeService $employeeScopeService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
protected function supports(string $attribute, mixed $subject): bool
|
|
||||||
{
|
|
||||||
return in_array($attribute, [self::VIEW, self::EDIT], true) && $subject instanceof WorkHour;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
|
|
||||||
{
|
|
||||||
// On ne traite que des utilisateurs applicatifs authentifiés.
|
|
||||||
$user = $this->security->getUser();
|
|
||||||
if (!$user instanceof User) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$subject instanceof WorkHour) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$employee = $subject->getEmployee();
|
|
||||||
if (null === $employee) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Délégation de la règle au service de scope unique (évite la duplication).
|
|
||||||
return $this->employeeScopeService->canAccessEmployee($user, $employee);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -114,7 +114,8 @@ class AbsencePrintProvider implements ProviderInterface
|
|||||||
->createQueryBuilder('e')
|
->createQueryBuilder('e')
|
||||||
->leftJoin('e.site', 's')
|
->leftJoin('e.site', 's')
|
||||||
->addSelect('s')
|
->addSelect('s')
|
||||||
->orderBy('s.name', 'ASC')
|
->orderBy('s.displayOrder', 'ASC')
|
||||||
|
->addOrderBy('s.name', 'ASC')
|
||||||
->addOrderBy('e.displayOrder', 'ASC')
|
->addOrderBy('e.displayOrder', 'ASC')
|
||||||
->addOrderBy('e.lastName', 'ASC')
|
->addOrderBy('e.lastName', 'ASC')
|
||||||
->addOrderBy('e.firstName', 'ASC')
|
->addOrderBy('e.firstName', 'ASC')
|
||||||
|
|||||||
@@ -1,384 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\State;
|
|
||||||
|
|
||||||
use ApiPlatform\Metadata\Operation;
|
|
||||||
use ApiPlatform\State\ProcessorInterface;
|
|
||||||
use App\ApiResource\WorkHourBulkUpsert;
|
|
||||||
use App\ApiResource\WorkHourBulkUpsertResult;
|
|
||||||
use App\Entity\Employee;
|
|
||||||
use App\Entity\User;
|
|
||||||
use App\Entity\WorkHour;
|
|
||||||
use App\Security\EmployeeScopeService;
|
|
||||||
use DateTimeImmutable;
|
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
|
||||||
use Symfony\Bundle\SecurityBundle\Security;
|
|
||||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
|
||||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
|
||||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
|
||||||
|
|
||||||
final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private EntityManagerInterface $entityManager,
|
|
||||||
private Security $security,
|
|
||||||
private EmployeeScopeService $employeeScopeService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function process(
|
|
||||||
mixed $data,
|
|
||||||
Operation $operation,
|
|
||||||
array $uriVariables = [],
|
|
||||||
array $context = []
|
|
||||||
): WorkHourBulkUpsertResult {
|
|
||||||
// Endpoint dédié au bulk: on refuse tout autre payload.
|
|
||||||
if (!$data instanceof WorkHourBulkUpsert) {
|
|
||||||
throw new BadRequestHttpException('Invalid payload.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = $this->security->getUser();
|
|
||||||
if (!$user instanceof User) {
|
|
||||||
throw new AccessDeniedHttpException('Authentication required.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$workDate = DateTimeImmutable::createFromFormat('Y-m-d', $data->workDate);
|
|
||||||
if (!$workDate || $workDate->format('Y-m-d') !== $data->workDate) {
|
|
||||||
throw new UnprocessableEntityHttpException('workDate must use Y-m-d format.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ([] === $data->entries) {
|
|
||||||
throw new UnprocessableEntityHttpException('entries must contain at least one employee.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vérifie que tous les employés envoyés sont dans le scope de l'utilisateur courant.
|
|
||||||
$employeeIds = $this->extractEmployeeIds($data->entries);
|
|
||||||
$employeesById = $this->loadAccessibleEmployees($employeeIds, $user);
|
|
||||||
|
|
||||||
if (count($employeesById) !== count($employeeIds)) {
|
|
||||||
throw new AccessDeniedHttpException('At least one employee is unknown or outside your scope.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$existingByEmployeeId = $this->loadExistingWorkHours($workDate, array_values($employeesById));
|
|
||||||
|
|
||||||
$result = new WorkHourBulkUpsertResult();
|
|
||||||
|
|
||||||
foreach ($data->entries as $entry) {
|
|
||||||
$employeeId = (int) $entry['employeeId'];
|
|
||||||
$employee = $employeesById[$employeeId] ?? null;
|
|
||||||
if (!$employee) {
|
|
||||||
throw new AccessDeniedHttpException(sprintf('Employee %d is outside your scope.', $employeeId));
|
|
||||||
}
|
|
||||||
|
|
||||||
$normalized = $this->normalizeEntry($entry, $employeeId);
|
|
||||||
$existing = $existingByEmployeeId[$employeeId] ?? null;
|
|
||||||
|
|
||||||
if ($this->isEntryEmpty($normalized)) {
|
|
||||||
// Convention choisie: une ligne vide supprime l'enregistrement existant.
|
|
||||||
if ($existing) {
|
|
||||||
$this->entityManager->remove($existing);
|
|
||||||
++$result->deleted;
|
|
||||||
}
|
|
||||||
|
|
||||||
++$result->processed;
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($existing) {
|
|
||||||
$workHour = $existing;
|
|
||||||
++$result->updated;
|
|
||||||
} else {
|
|
||||||
// Upsert: création si aucune ligne n'existe pour (employé, date).
|
|
||||||
$workHour = new WorkHour()
|
|
||||||
->setEmployee($employee)
|
|
||||||
->setWorkDate($workDate)
|
|
||||||
;
|
|
||||||
$this->entityManager->persist($workHour);
|
|
||||||
++$result->created;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->hydrateWorkHour($workHour, $normalized);
|
|
||||||
++$result->processed;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->entityManager->flush();
|
|
||||||
|
|
||||||
return $result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param list<array<string, mixed>> $entries
|
|
||||||
*
|
|
||||||
* @return list<int>
|
|
||||||
*/
|
|
||||||
private function extractEmployeeIds(array $entries): array
|
|
||||||
{
|
|
||||||
$ids = [];
|
|
||||||
foreach ($entries as $index => $entry) {
|
|
||||||
if (!is_array($entry) || !array_key_exists('employeeId', $entry)) {
|
|
||||||
throw new UnprocessableEntityHttpException(sprintf('entries[%d].employeeId is required.', $index));
|
|
||||||
}
|
|
||||||
|
|
||||||
$employeeId = (int) $entry['employeeId'];
|
|
||||||
if ($employeeId <= 0) {
|
|
||||||
throw new UnprocessableEntityHttpException(sprintf('entries[%d].employeeId must be a positive integer.', $index));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isset($ids[$employeeId])) {
|
|
||||||
throw new UnprocessableEntityHttpException(sprintf('Employee %d appears multiple times in the same bulk payload.', $employeeId));
|
|
||||||
}
|
|
||||||
|
|
||||||
$ids[$employeeId] = $employeeId;
|
|
||||||
}
|
|
||||||
|
|
||||||
return array_values($ids);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param list<int> $employeeIds
|
|
||||||
*
|
|
||||||
* @return array<int, Employee>
|
|
||||||
*/
|
|
||||||
private function loadAccessibleEmployees(array $employeeIds, User $user): array
|
|
||||||
{
|
|
||||||
if ([] === $employeeIds) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$qb = $this->entityManager
|
|
||||||
->getRepository(Employee::class)
|
|
||||||
->createQueryBuilder('e')
|
|
||||||
->andWhere('e.id IN (:ids)')
|
|
||||||
->setParameter('ids', $employeeIds)
|
|
||||||
;
|
|
||||||
|
|
||||||
$this->employeeScopeService->applyEmployeeScope($qb, 'e', 'bulk_scope', $user);
|
|
||||||
|
|
||||||
/** @var list<Employee> $employees */
|
|
||||||
$employees = $qb->getQuery()->getResult();
|
|
||||||
|
|
||||||
$byId = [];
|
|
||||||
foreach ($employees as $employee) {
|
|
||||||
$employeeId = $employee->getId();
|
|
||||||
if ($employeeId) {
|
|
||||||
$byId[$employeeId] = $employee;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $byId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param list<Employee> $employees
|
|
||||||
*
|
|
||||||
* @return array<int, WorkHour>
|
|
||||||
*/
|
|
||||||
private function loadExistingWorkHours(DateTimeImmutable $workDate, array $employees): array
|
|
||||||
{
|
|
||||||
if ([] === $employees) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$qb = $this->entityManager
|
|
||||||
->getRepository(WorkHour::class)
|
|
||||||
->createQueryBuilder('w')
|
|
||||||
->leftJoin('w.employee', 'e')
|
|
||||||
->addSelect('e')
|
|
||||||
->andWhere('w.workDate = :workDate')
|
|
||||||
->andWhere('w.employee IN (:employees)')
|
|
||||||
->setParameter('workDate', $workDate)
|
|
||||||
->setParameter('employees', $employees)
|
|
||||||
;
|
|
||||||
|
|
||||||
/** @var list<WorkHour> $workHours */
|
|
||||||
$workHours = $qb->getQuery()->getResult();
|
|
||||||
|
|
||||||
$byEmployeeId = [];
|
|
||||||
foreach ($workHours as $workHour) {
|
|
||||||
$employeeId = $workHour->getEmployee()?->getId();
|
|
||||||
if ($employeeId) {
|
|
||||||
$byEmployeeId[$employeeId] = $workHour;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $byEmployeeId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $entry
|
|
||||||
*
|
|
||||||
* @return array{
|
|
||||||
* morningFrom:?string,
|
|
||||||
* morningTo:?string,
|
|
||||||
* afternoonFrom:?string,
|
|
||||||
* afternoonTo:?string,
|
|
||||||
* eveningFrom:?string,
|
|
||||||
* eveningTo:?string
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
private function normalizeEntry(array $entry, int $employeeId): array
|
|
||||||
{
|
|
||||||
$normalized = [
|
|
||||||
'morningFrom' => $this->normalizeTime($entry['morningFrom'] ?? null, $employeeId, 'morningFrom'),
|
|
||||||
'morningTo' => $this->normalizeTime($entry['morningTo'] ?? null, $employeeId, 'morningTo'),
|
|
||||||
'afternoonFrom' => $this->normalizeTime($entry['afternoonFrom'] ?? null, $employeeId, 'afternoonFrom'),
|
|
||||||
'afternoonTo' => $this->normalizeTime($entry['afternoonTo'] ?? null, $employeeId, 'afternoonTo'),
|
|
||||||
'eveningFrom' => $this->normalizeTime($entry['eveningFrom'] ?? null, $employeeId, 'eveningFrom'),
|
|
||||||
'eveningTo' => $this->normalizeTime($entry['eveningTo'] ?? null, $employeeId, 'eveningTo'),
|
|
||||||
];
|
|
||||||
|
|
||||||
$this->validateRanges($normalized, $employeeId);
|
|
||||||
|
|
||||||
return $normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function normalizeTime(mixed $value, int $employeeId, string $field): ?string
|
|
||||||
{
|
|
||||||
if (null === $value || '' === $value) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!is_string($value)) {
|
|
||||||
throw new UnprocessableEntityHttpException(sprintf(
|
|
||||||
'Employee %d: %s must be a string in HH:MM format.',
|
|
||||||
$employeeId,
|
|
||||||
$field
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
$time = trim($value);
|
|
||||||
if (!preg_match('/^(?:[01]\d|2[0-3]):[0-5]\d$/', $time)) {
|
|
||||||
throw new UnprocessableEntityHttpException(sprintf(
|
|
||||||
'Employee %d: %s must use HH:MM format.',
|
|
||||||
$employeeId,
|
|
||||||
$field
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
return $time;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array{
|
|
||||||
* morningFrom:?string,
|
|
||||||
* morningTo:?string,
|
|
||||||
* afternoonFrom:?string,
|
|
||||||
* afternoonTo:?string,
|
|
||||||
* eveningFrom:?string,
|
|
||||||
* eveningTo:?string
|
|
||||||
* } $entry
|
|
||||||
*/
|
|
||||||
private function validateRanges(array $entry, int $employeeId): void
|
|
||||||
{
|
|
||||||
$ranges = [
|
|
||||||
'morning' => [$entry['morningFrom'], $entry['morningTo']],
|
|
||||||
'afternoon' => [$entry['afternoonFrom'], $entry['afternoonTo']],
|
|
||||||
'evening' => [$entry['eveningFrom'], $entry['eveningTo']],
|
|
||||||
];
|
|
||||||
|
|
||||||
$normalizedRanges = [];
|
|
||||||
|
|
||||||
foreach ($ranges as $label => [$from, $to]) {
|
|
||||||
// On force des paires from/to complètes par créneau.
|
|
||||||
if ((null === $from) xor (null === $to)) {
|
|
||||||
throw new UnprocessableEntityHttpException(sprintf(
|
|
||||||
'Employee %d: %s range must contain both from and to.',
|
|
||||||
$employeeId,
|
|
||||||
$label
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (null === $from || null === $to) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$fromMinutes = $this->toMinutes($from);
|
|
||||||
$toMinutes = $this->toMinutes($to);
|
|
||||||
|
|
||||||
if ($fromMinutes >= $toMinutes) {
|
|
||||||
throw new UnprocessableEntityHttpException(sprintf(
|
|
||||||
'Employee %d: %s from must be earlier than to.',
|
|
||||||
$employeeId,
|
|
||||||
$label
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
$normalizedRanges[] = [
|
|
||||||
'label' => $label,
|
|
||||||
'from' => $fromMinutes,
|
|
||||||
'to' => $toMinutes,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
usort(
|
|
||||||
$normalizedRanges,
|
|
||||||
static fn (array $rangeA, array $rangeB): int => $rangeA['from'] <=> $rangeB['from']
|
|
||||||
);
|
|
||||||
|
|
||||||
$previous = null;
|
|
||||||
foreach ($normalizedRanges as $range) {
|
|
||||||
// Empêche deux créneaux qui se chevauchent sur une même journée.
|
|
||||||
if (null !== $previous && $range['from'] < $previous['to']) {
|
|
||||||
throw new UnprocessableEntityHttpException(sprintf(
|
|
||||||
'Employee %d: %s overlaps %s.',
|
|
||||||
$employeeId,
|
|
||||||
$range['label'],
|
|
||||||
$previous['label']
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
$previous = $range;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array{
|
|
||||||
* morningFrom:?string,
|
|
||||||
* morningTo:?string,
|
|
||||||
* afternoonFrom:?string,
|
|
||||||
* afternoonTo:?string,
|
|
||||||
* eveningFrom:?string,
|
|
||||||
* eveningTo:?string
|
|
||||||
* } $entry
|
|
||||||
*/
|
|
||||||
private function isEntryEmpty(array $entry): bool
|
|
||||||
{
|
|
||||||
return null === $entry['morningFrom']
|
|
||||||
&& null === $entry['morningTo']
|
|
||||||
&& null === $entry['afternoonFrom']
|
|
||||||
&& null === $entry['afternoonTo']
|
|
||||||
&& null === $entry['eveningFrom']
|
|
||||||
&& null === $entry['eveningTo'];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array{
|
|
||||||
* morningFrom:?string,
|
|
||||||
* morningTo:?string,
|
|
||||||
* afternoonFrom:?string,
|
|
||||||
* afternoonTo:?string,
|
|
||||||
* eveningFrom:?string,
|
|
||||||
* eveningTo:?string
|
|
||||||
* } $entry
|
|
||||||
*/
|
|
||||||
private function hydrateWorkHour(WorkHour $workHour, array $entry): void
|
|
||||||
{
|
|
||||||
$workHour
|
|
||||||
->setMorningFrom($entry['morningFrom'])
|
|
||||||
->setMorningTo($entry['morningTo'])
|
|
||||||
->setAfternoonFrom($entry['afternoonFrom'])
|
|
||||||
->setAfternoonTo($entry['afternoonTo'])
|
|
||||||
->setEveningFrom($entry['eveningFrom'])
|
|
||||||
->setEveningTo($entry['eveningTo'])
|
|
||||||
;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function toMinutes(string $time): int
|
|
||||||
{
|
|
||||||
[$hours, $minutes] = array_map('intval', explode(':', $time, 2));
|
|
||||||
|
|
||||||
return ($hours * 60) + $minutes;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -35,29 +35,27 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.col-employee {
|
.col-employee {
|
||||||
font-size: 16px;
|
font-size: 10px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
width: 10mm;
|
width: 10mm;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.col-day {
|
.col-day {
|
||||||
font-size: 10px;
|
font-size: 8px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.month {
|
.month {
|
||||||
font-size: 16px;
|
font-size: 14px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
padding: 3px 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-title td {
|
.site-title td {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
font-size: 16px;
|
font-size: 12px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
border-color: #0a0a0a;
|
border-color: #0a0a0a;
|
||||||
padding: 4px 6px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-title .label {
|
.site-title .label {
|
||||||
@@ -66,7 +64,7 @@
|
|||||||
|
|
||||||
.code {
|
.code {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
font-size: 9px;
|
font-size: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.holiday-code {
|
.holiday-code {
|
||||||
@@ -95,8 +93,6 @@
|
|||||||
|
|
||||||
.full-cell {
|
.full-cell {
|
||||||
display: block;
|
display: block;
|
||||||
height: 6mm;
|
|
||||||
line-height: 6mm;
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -104,7 +100,6 @@
|
|||||||
|
|
||||||
.half-table {
|
.half-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 6mm;
|
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
table-layout: fixed;
|
table-layout: fixed;
|
||||||
border: 0;
|
border: 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user