Ajouter le suivi annuel des horaires et permanences
Some checks are pending
CI - Tests et Syntax / lint-and-test (push) Waiting to run
Some checks are pending
CI - Tests et Syntax / lint-and-test (push) Waiting to run
This commit is contained in:
parent
4d6e4be906
commit
a62e644e3d
9 changed files with 453 additions and 18 deletions
|
|
@ -11,7 +11,7 @@ from .maintenance import (
|
|||
)
|
||||
from .company import Company, Part, PartStockMovement, Alert, Service
|
||||
from .planning import (
|
||||
WorkSchedule, CollegeClosure, ClosureSchedule, ClosureWorkDay, PersonalLeave, Training, TrainingParticipant,
|
||||
WorkSchedule, WorkScheduleTemplate, AnnualTimeConfig, TimeEntry, CollegeClosure, ClosureSchedule, ClosureWorkDay, PersonalLeave, Training, TrainingParticipant,
|
||||
Meter, MeterReading, Consumable, ConsumableUsage, EquipmentConsumable,
|
||||
PreventiveTask, PreventiveTaskConsumable, ScheduledTask,
|
||||
TechnicianAvailability, AdminTask, ZoneAccessRule
|
||||
|
|
@ -27,7 +27,7 @@ __all__ = [
|
|||
'Intervention', 'StatusChange', 'InterventionComment', 'InterventionDocument',
|
||||
'Lot', 'LotTask', 'LotService',
|
||||
'Company', 'Part', 'PartStockMovement', 'InterventionPart', 'Alert', 'Service',
|
||||
'WorkSchedule', 'CollegeClosure', 'ClosureSchedule', 'ClosureWorkDay', 'PersonalLeave', 'Training', 'TrainingParticipant',
|
||||
'WorkSchedule', 'WorkScheduleTemplate', 'AnnualTimeConfig', 'TimeEntry', 'CollegeClosure', 'ClosureSchedule', 'ClosureWorkDay', 'PersonalLeave', 'Training', 'TrainingParticipant',
|
||||
'Meter', 'MeterReading', 'Consumable', 'ConsumableUsage', 'EquipmentConsumable',
|
||||
'PreventiveTask', 'PreventiveTaskConsumable', 'ScheduledTask',
|
||||
'TechnicianAvailability', 'AdminTask', 'ZoneAccessRule',
|
||||
|
|
|
|||
|
|
@ -17,15 +17,82 @@ class WorkSchedule(db.Model):
|
|||
end_time = db.Column(db.Time)
|
||||
lunch_start = db.Column(db.Time, nullable=True) # Début pause déjeuner
|
||||
lunch_end = db.Column(db.Time, nullable=True) # Fin pause déjeuner
|
||||
template_id = db.Column(db.Integer, db.ForeignKey("work_schedule_templates.id"), nullable=True)
|
||||
|
||||
is_active = db.Column(db.Boolean, default=True)
|
||||
|
||||
user = db.relationship("User", backref=db.backref("work_schedules", lazy="dynamic"))
|
||||
template = db.relationship("WorkScheduleTemplate", back_populates="assignments")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<WorkSchedule {self.day_of_week} {self.start_time}-{self.end_time}>"
|
||||
|
||||
|
||||
class WorkScheduleTemplate(db.Model):
|
||||
"""Un des trois profils horaires paramétrables de l'agent."""
|
||||
__tablename__ = "work_schedule_templates"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True)
|
||||
name = db.Column(db.String(80), nullable=False)
|
||||
start_time = db.Column(db.Time, nullable=False)
|
||||
end_time = db.Column(db.Time, nullable=False)
|
||||
lunch_start = db.Column(db.Time, nullable=True)
|
||||
lunch_end = db.Column(db.Time, nullable=True)
|
||||
is_active = db.Column(db.Boolean, nullable=False, default=True)
|
||||
|
||||
user = db.relationship("User", backref=db.backref("work_schedule_templates", lazy="dynamic"))
|
||||
assignments = db.relationship("WorkSchedule", back_populates="template")
|
||||
|
||||
@property
|
||||
def duration_minutes(self):
|
||||
total = (self.end_time.hour * 60 + self.end_time.minute) - (self.start_time.hour * 60 + self.start_time.minute)
|
||||
if self.lunch_start and self.lunch_end:
|
||||
total -= (self.lunch_end.hour * 60 + self.lunch_end.minute) - (self.lunch_start.hour * 60 + self.lunch_start.minute)
|
||||
return max(total, 0)
|
||||
|
||||
|
||||
class AnnualTimeConfig(db.Model):
|
||||
"""Objectif annuel et déduction de pénibilité pour un agent."""
|
||||
__tablename__ = "annual_time_configs"
|
||||
__table_args__ = (db.UniqueConstraint("user_id", "year", name="uq_annual_time_user_year"),)
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True)
|
||||
year = db.Column(db.Integer, nullable=False)
|
||||
target_minutes = db.Column(db.Integer, nullable=False, default=1607 * 60)
|
||||
hardship_deduction_minutes = db.Column(db.Integer, nullable=False, default=0)
|
||||
notes = db.Column(db.Text, nullable=True)
|
||||
|
||||
user = db.relationship("User", backref=db.backref("annual_time_configs", lazy="dynamic"))
|
||||
|
||||
@property
|
||||
def minutes_to_work(self):
|
||||
return max((self.target_minutes or 0) - (self.hardship_deduction_minutes or 0), 0)
|
||||
|
||||
|
||||
class TimeEntry(db.Model):
|
||||
"""Suivi réel d'une journée : retard, présence, permanence ou heures sup."""
|
||||
__tablename__ = "work_time_entries"
|
||||
__table_args__ = (db.UniqueConstraint("user_id", "work_date", name="uq_work_time_user_date"),)
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True)
|
||||
work_date = db.Column(db.Date, nullable=False, index=True)
|
||||
planned_minutes = db.Column(db.Integer, nullable=False, default=0)
|
||||
actual_minutes = db.Column(db.Integer, nullable=False, default=0)
|
||||
entry_type = db.Column(db.String(30), nullable=False, default="travail")
|
||||
note = db.Column(db.Text, nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
|
||||
|
||||
user = db.relationship("User", backref=db.backref("work_time_entries", lazy="dynamic"))
|
||||
|
||||
@property
|
||||
def variance_minutes(self):
|
||||
return (self.actual_minutes or 0) - (self.planned_minutes or 0)
|
||||
|
||||
|
||||
class CollegeClosure(db.Model):
|
||||
"""Fermetures du collège / Vacances scolaires."""
|
||||
__tablename__ = "college_closures"
|
||||
|
|
|
|||
|
|
@ -193,9 +193,10 @@ class PlanningService:
|
|||
# Horaires normaux - chercher le premier schedule avec des horaires valides
|
||||
schedules = WorkSchedule.query.filter_by(day_of_week=day_of_week).all()
|
||||
for schedule in schedules:
|
||||
if schedule.start_time and schedule.end_time:
|
||||
return (schedule.start_time, schedule.end_time,
|
||||
schedule.lunch_start, schedule.lunch_end)
|
||||
source = schedule.template if getattr(schedule, 'template', None) and schedule.template.is_active else schedule
|
||||
if source.start_time and source.end_time:
|
||||
return (source.start_time, source.end_time,
|
||||
source.lunch_start, source.lunch_end)
|
||||
|
||||
# Si un schedule existe mais sans horaires, considérer comme non travaillé
|
||||
if schedules:
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ def import_vacances_scolaires(zone="A", annee=None, user_id=None):
|
|||
for vac in vacances:
|
||||
# Vérifier si cette fermeture existe déjà
|
||||
existing = CollegeClosure.query.filter(
|
||||
CollegeClosure.type == "vacances_scolaires",
|
||||
CollegeClosure.closure_type == "vacances_scolaires",
|
||||
CollegeClosure.start_date == vac["start_date"],
|
||||
CollegeClosure.end_date == vac["end_date"]
|
||||
).first()
|
||||
|
|
@ -94,13 +94,12 @@ def import_vacances_scolaires(zone="A", annee=None, user_id=None):
|
|||
|
||||
try:
|
||||
closure = CollegeClosure(
|
||||
type="vacances_scolaires",
|
||||
label=vac["description"],
|
||||
name=vac["description"],
|
||||
start_date=vac["start_date"],
|
||||
end_date=vac["end_date"],
|
||||
zone=zone,
|
||||
is_exception=False,
|
||||
created_by_id=user_id
|
||||
closure_type="vacances_scolaires",
|
||||
work_hours_type="none",
|
||||
notes=f"Zone académique {zone} — import officiel",
|
||||
)
|
||||
db.session.add(closure)
|
||||
ajoutees += 1
|
||||
|
|
@ -229,7 +228,7 @@ def import_jours_feries(annee=None, user_id=None):
|
|||
|
||||
for date, nom in jours:
|
||||
existing = CollegeClosure.query.filter(
|
||||
CollegeClosure.type == "jour_ferie",
|
||||
CollegeClosure.closure_type == "jour_ferie",
|
||||
CollegeClosure.start_date == date,
|
||||
CollegeClosure.end_date == date
|
||||
).first()
|
||||
|
|
@ -239,13 +238,12 @@ def import_jours_feries(annee=None, user_id=None):
|
|||
continue
|
||||
|
||||
closure = CollegeClosure(
|
||||
type="jour_ferie",
|
||||
label=nom,
|
||||
name=nom,
|
||||
start_date=date,
|
||||
end_date=date,
|
||||
zone=None,
|
||||
is_exception=False,
|
||||
created_by_id=user_id
|
||||
closure_type="jour_ferie",
|
||||
work_hours_type="none",
|
||||
notes="Import automatique des jours fériés français",
|
||||
)
|
||||
db.session.add(closure)
|
||||
ajoutees += 1
|
||||
|
|
@ -253,4 +251,4 @@ def import_jours_feries(annee=None, user_id=None):
|
|||
if ajoutees > 0:
|
||||
db.session.commit()
|
||||
|
||||
return ajoutees, ignorees
|
||||
return ajoutees, ignorees
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ Ce module re-exporte le blueprint apres avoir charge tous les sous-modules.
|
|||
# Import dans cet ordre pour attacher les routes
|
||||
from . import schedules
|
||||
from . import closures
|
||||
from . import time_tracking
|
||||
from . import admin_rules
|
||||
from .schedules import planning_bp
|
||||
|
||||
|
|
|
|||
47
app_new/planning/templates/planning/time_tracking.html
Normal file
47
app_new/planning/templates/planning/time_tracking.html
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Horaires et suivi annuel — GMAO{% endblock %}
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div><h1 class="h3"><i class="bi bi-clock-history me-2"></i>Horaires et suivi des heures</h1><p class="text-muted mb-0">Configurez vos profils, permanences et écarts d'heures.</p></div>
|
||||
<div class="btn-group"><a class="btn btn-outline-secondary" href="{{ url_for('planning.time_tracking', year=year-1) }}">{{ year-1 }}</a><a class="btn btn-outline-secondary" href="{{ url_for('planning.time_tracking', year=year+1) }}">{{ year+1 }}</a></div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-3"><div class="card border-primary h-100"><div class="card-body"><small>À réaliser</small><h3>{{ '%d h %02d'|format(required_minutes // 60, required_minutes % 60) }}</h3><small class="text-muted">1607 h moins pénibilité</small></div></div></div>
|
||||
<div class="col-md-3"><div class="card border-info h-100"><div class="card-body"><small>Prévu par calendrier</small><h3>{{ '%d h %02d'|format(planned_minutes // 60, planned_minutes % 60) }}</h3></div></div></div>
|
||||
<div class="col-md-3"><div class="card border-success h-100"><div class="card-body"><small>Réalisé saisi</small><h3>{{ '%d h %02d'|format(actual_minutes // 60, actual_minutes % 60) }}</h3></div></div></div>
|
||||
<div class="col-md-3"><div class="card {% if variance_minutes >= 0 %}border-success{% else %}border-danger{% endif %} h-100"><div class="card-body"><small>Solde réalisé / dû</small><h3 class="{% if variance_minutes >= 0 %}text-success{% else %}text-danger{% endif %}">{{ '+' if variance_minutes >= 0 else '' }}{{ '%d h %02d'|format(abs(variance_minutes) // 60, abs(variance_minutes) % 60) }}</h3><small>{% if variance_minutes >= 0 %}heures à récupérer{% else %}heures à faire{% endif %}</small></div></div></div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-lg-5">
|
||||
<div class="card mb-3"><div class="card-header"><strong>Objectif annuel {{ year }}</strong></div><div class="card-body">
|
||||
<form method="post" action="{{ url_for('planning.save_time_config') }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><input type="hidden" name="year" value="{{ year }}">
|
||||
<div class="row g-2"><div class="col-6"><label class="form-label">Durée annuelle (h)</label><input class="form-control" type="number" step="0.01" name="target_hours" value="{{ '%.2f'|format(time_config.target_minutes / 60) }}"></div><div class="col-6"><label class="form-label">Déduction pénibilité (h)</label><input class="form-control" type="number" step="0.01" min="0" name="hardship_hours" value="{{ '%.2f'|format(time_config.hardship_deduction_minutes / 60) }}"></div></div>
|
||||
<label class="form-label mt-2">Notes</label><textarea class="form-control" name="notes" rows="2">{{ time_config.notes or '' }}</textarea><button class="btn btn-primary mt-2">Enregistrer</button>
|
||||
</form>
|
||||
</div></div>
|
||||
|
||||
<div class="card mb-3"><div class="card-header d-flex justify-content-between"><strong>Horaires types (maximum 3)</strong><span class="badge bg-primary">{{ templates|length }}/3</span></div><div class="card-body">
|
||||
{% for template in templates %}<div class="border-bottom py-2"><div class="d-flex justify-content-between"><span><strong>{{ template.name }}</strong><br><small>{{ template.start_time.strftime('%H:%M') }}–{{ template.end_time.strftime('%H:%M') }}{% if template.lunch_start %} · pause {{ template.lunch_start.strftime('%H:%M') }}–{{ template.lunch_end.strftime('%H:%M') }}{% endif %}</small></span><form method="post" action="{{ url_for('planning.delete_time_template', id=template.id) }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="btn btn-sm btn-outline-danger"><i class="bi bi-trash"></i></button></form></div><form method="post" action="{{ url_for('planning.edit_time_template', id=template.id) }}" class="row g-1 mt-1"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><div class="col-4"><input class="form-control form-control-sm" name="name" value="{{ template.name }}"></div><div class="col-2"><input class="form-control form-control-sm" type="time" name="start_time" value="{{ template.start_time.strftime('%H:%M') }}"></div><div class="col-2"><input class="form-control form-control-sm" type="time" name="end_time" value="{{ template.end_time.strftime('%H:%M') }}"></div><div class="col-2"><input class="form-control form-control-sm" type="time" name="lunch_start" value="{{ template.lunch_start.strftime('%H:%M') if template.lunch_start else '' }}"></div><div class="col-2"><button class="btn btn-sm btn-outline-primary w-100">Modifier</button></div></form></div>{% else %}<p class="text-muted">Aucun profil défini.</p>{% endfor %}
|
||||
{% if templates|length < 3 %}<form method="post" action="{{ url_for('planning.save_time_template') }}" class="mt-3"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><input class="form-control mb-2" name="name" placeholder="Nom : journée normale, été…" required><div class="row g-2"><div class="col-6"><input class="form-control" type="time" name="start_time" required></div><div class="col-6"><input class="form-control" type="time" name="end_time" required></div><div class="col-6"><input class="form-control" type="time" name="lunch_start" placeholder="Début pause"></div><div class="col-6"><input class="form-control" type="time" name="lunch_end" placeholder="Fin pause"></div></div><button class="btn btn-outline-primary btn-sm mt-2">Ajouter l’horaire type</button></form>{% endif %}
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-7">
|
||||
<div class="card mb-3"><div class="card-header"><strong>Affectation par jour</strong></div><div class="card-body"><div class="row g-2">
|
||||
{% for day_num, day_name in days.items() %}<div class="col-md-6"><form method="post" action="{{ url_for('planning.assign_time_template') }}" class="input-group"><span class="input-group-text">{{ day_name }}</span><input type="hidden" name="day_of_week" value="{{ day_num }}"><select class="form-select" name="template_id" onchange="this.form.submit()"><option value="">Non travaillé</option>{% for template in templates %}<option value="{{ template.id }}" {% if assignments.get(day_num) and assignments[day_num].template_id == template.id %}selected{% endif %}>{{ template.name }}</option>{% endfor %}</select></form></div>{% endfor %}
|
||||
</div></div></div>
|
||||
|
||||
<div class="card mb-3"><div class="card-header"><strong>Importer les calendriers</strong></div><div class="card-body"><div class="row g-2"><div class="col-md-6"><form method="post" action="{{ url_for('planning.import_school_vacations') }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><input type="hidden" name="year" value="{{ year }}"><div class="input-group"><select class="form-select" name="zone"><option value="B">Zone B (Seine-et-Marne)</option><option value="A">Zone A</option><option value="C">Zone C</option></select><button class="btn btn-outline-primary">Importer vacances</button></div></form></div><div class="col-md-6"><form method="post" action="{{ url_for('planning.import_public_holidays') }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><input type="hidden" name="year" value="{{ year }}"><button class="btn btn-outline-primary">Importer jours fériés {{ year }}</button></form></div></div></div></div>
|
||||
|
||||
<div class="card mb-3"><div class="card-header"><strong>Ajouter une permanence</strong><small class="d-block text-muted">Une permanence ouvre une date précise dans une période de vacances.</small></div><div class="card-body"><form method="post" action="{{ url_for('planning.add_permanence') }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><div class="row g-2"><div class="col-md-3"><input class="form-control" type="date" name="work_date" required></div><div class="col-md-2"><input class="form-control" type="time" name="start_time" required></div><div class="col-md-2"><input class="form-control" type="time" name="end_time" required></div><div class="col-md-3"><input class="form-control" name="notes" placeholder="Ex. permanence rentrée"></div><div class="col-md-2"><button class="btn btn-success w-100">Ajouter</button></div></div></form></div></div>
|
||||
|
||||
<div class="card mb-3"><div class="card-header"><strong>Saisir un retard ou une heure supplémentaire</strong></div><div class="card-body"><form method="post" action="{{ url_for('planning.save_time_entry') }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><div class="row g-2"><div class="col-md-3"><input class="form-control" type="date" name="work_date" required></div><div class="col-md-2"><input class="form-control" type="number" name="planned_minutes" placeholder="Prévu (min)" value="0"></div><div class="col-md-2"><input class="form-control" type="number" name="actual_minutes" placeholder="Réalisé (min)" value="0"></div><div class="col-md-2"><select class="form-select" name="entry_type"><option value="travail">Travail</option><option value="retard">Retard</option><option value="supplementaire">Heures sup.</option><option value="permanence">Permanence</option></select></div><div class="col-md-3"><input class="form-control" name="note" placeholder="Note"></div></div><button class="btn btn-primary mt-2">Enregistrer la journée</button></form></div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card"><div class="card-header"><strong>Historique {{ year }}</strong></div><div class="table-responsive"><table class="table table-sm mb-0"><thead><tr><th>Date</th><th>Type</th><th>Prévu</th><th>Réalisé</th><th>Écart</th><th>Note</th></tr></thead><tbody>{% for entry in entries[:50] %}<tr><td>{{ entry.work_date.strftime('%d/%m/%Y') }}</td><td>{{ entry.entry_type }}</td><td>{{ entry.planned_minutes }} min</td><td>{{ entry.actual_minutes }} min</td><td class="{% if entry.variance_minutes < 0 %}text-danger{% elif entry.variance_minutes > 0 %}text-success{% endif %}">{{ '+' if entry.variance_minutes > 0 else '' }}{{ entry.variance_minutes }} min</td><td>{{ entry.note or '—' }}</td></tr>{% else %}<tr><td colspan="6" class="text-muted">Aucune saisie.</td></tr>{% endfor %}</tbody></table></div></div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
255
app_new/planning/time_tracking.py
Normal file
255
app_new/planning/time_tracking.py
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
"""Configuration des horaires et suivi annuel des heures de l'agent."""
|
||||
from datetime import date, datetime, timedelta
|
||||
|
||||
from flask import flash, redirect, render_template, request, url_for
|
||||
from flask_login import current_user, login_required
|
||||
|
||||
from ..extensions import db
|
||||
from ..core.models.planning import (
|
||||
AnnualTimeConfig, ClosureWorkDay, CollegeClosure, TimeEntry,
|
||||
WorkSchedule, WorkScheduleTemplate,
|
||||
)
|
||||
from .schedules import planning_bp
|
||||
|
||||
|
||||
def _time(value):
|
||||
return datetime.strptime(value, "%H:%M").time() if value else None
|
||||
|
||||
|
||||
def _minutes(start, end, lunch_start=None, lunch_end=None):
|
||||
if not start or not end:
|
||||
return 0
|
||||
value = (end.hour * 60 + end.minute) - (start.hour * 60 + start.minute)
|
||||
if lunch_start and lunch_end:
|
||||
value -= (lunch_end.hour * 60 + lunch_end.minute) - (lunch_start.hour * 60 + lunch_start.minute)
|
||||
return max(value, 0)
|
||||
|
||||
|
||||
def _redirect(year=None):
|
||||
return redirect(url_for("planning.time_tracking", year=year or date.today().year))
|
||||
|
||||
|
||||
@planning_bp.route("/time")
|
||||
@login_required
|
||||
def time_tracking():
|
||||
year = request.args.get("year", date.today().year, type=int)
|
||||
config = AnnualTimeConfig.query.filter_by(user_id=current_user.id, year=year).first()
|
||||
if not config:
|
||||
config = AnnualTimeConfig(user_id=current_user.id, year=year)
|
||||
db.session.add(config)
|
||||
db.session.commit()
|
||||
templates = WorkScheduleTemplate.query.filter_by(user_id=current_user.id, is_active=True).order_by(WorkScheduleTemplate.id).all()
|
||||
assignments = {item.day_of_week: item for item in WorkSchedule.query.filter_by(user_id=current_user.id, is_active=True).all()}
|
||||
closures = CollegeClosure.query.filter(
|
||||
CollegeClosure.end_date >= date(year, 1, 1), CollegeClosure.start_date <= date(year, 12, 31)
|
||||
).order_by(CollegeClosure.start_date).all()
|
||||
from ..core.models.planning import ClosureSchedule, PersonalLeave, Training, TrainingParticipant
|
||||
from ..core.services.planning_service import get_french_public_holidays
|
||||
closure_ids = [item.id for item in closures]
|
||||
work_days = ClosureWorkDay.query.filter(ClosureWorkDay.closure_id.in_(closure_ids)).all() if closure_ids else []
|
||||
closure_schedules = ClosureSchedule.query.filter(ClosureSchedule.closure_id.in_(closure_ids), ClosureSchedule.is_active.is_(True)).all() if closure_ids else []
|
||||
work_day_map = {item.work_date: item for item in work_days}
|
||||
closure_schedule_map = {(item.closure_id, item.day_of_week): item for item in closure_schedules}
|
||||
holidays = {item[0] for item in get_french_public_holidays(year)}
|
||||
leaves = PersonalLeave.query.filter(PersonalLeave.user_id == current_user.id, PersonalLeave.end_date >= date(year, 1, 1), PersonalLeave.start_date <= date(year, 12, 31)).all()
|
||||
leave_dates = {day for leave in leaves for day in (leave.start_date + timedelta(days=i) for i in range((leave.end_date - leave.start_date).days + 1))}
|
||||
entries = TimeEntry.query.filter(
|
||||
TimeEntry.user_id == current_user.id,
|
||||
TimeEntry.work_date >= date(year, 1, 1), TimeEntry.work_date <= date(year, 12, 31),
|
||||
).order_by(TimeEntry.work_date.desc()).all()
|
||||
planned = 0
|
||||
current = date(year, 1, 1)
|
||||
while current.year == year:
|
||||
hours = None
|
||||
if current not in holidays and current not in leave_dates:
|
||||
closure = next((item for item in closures if item.start_date <= current <= item.end_date), None)
|
||||
if closure:
|
||||
exceptional = work_day_map.get(current)
|
||||
if exceptional:
|
||||
hours = (exceptional.start_time, exceptional.end_time, exceptional.lunch_start, exceptional.lunch_end)
|
||||
elif closure.work_hours_type == "reduced":
|
||||
hours = (_time("09:00"), _time("12:00"), None, None)
|
||||
elif closure.work_hours_type == "custom":
|
||||
item = closure_schedule_map.get((closure.id, current.weekday()))
|
||||
if item:
|
||||
hours = (item.start_time, item.end_time, item.lunch_start, item.lunch_end)
|
||||
elif current.weekday() < 5 and assignments.get(current.weekday()):
|
||||
assignment = assignments[current.weekday()]
|
||||
source = assignment.template if assignment.template and assignment.template.is_active else assignment
|
||||
hours = (source.start_time, source.end_time, source.lunch_start, source.lunch_end)
|
||||
if hours:
|
||||
planned += _minutes(*hours)
|
||||
current += timedelta(days=1)
|
||||
actual = sum(item.actual_minutes or 0 for item in entries)
|
||||
return render_template(
|
||||
"planning/time_tracking.html", year=year, time_config=config, templates=templates,
|
||||
assignments=assignments, closures=closures, entries=entries,
|
||||
days={0: "Lundi", 1: "Mardi", 2: "Mercredi", 3: "Jeudi", 4: "Vendredi", 5: "Samedi", 6: "Dimanche"},
|
||||
planned_minutes=planned, actual_minutes=actual,
|
||||
required_minutes=config.minutes_to_work, variance_minutes=actual - config.minutes_to_work,
|
||||
)
|
||||
|
||||
|
||||
@planning_bp.route("/time/config", methods=["POST"])
|
||||
@login_required
|
||||
def save_time_config():
|
||||
year = request.form.get("year", date.today().year, type=int)
|
||||
config = AnnualTimeConfig.query.filter_by(user_id=current_user.id, year=year).first()
|
||||
if not config:
|
||||
config = AnnualTimeConfig(user_id=current_user.id, year=year)
|
||||
db.session.add(config)
|
||||
config.target_minutes = round(float(request.form.get("target_hours") or 1607) * 60)
|
||||
config.hardship_deduction_minutes = round(float(request.form.get("hardship_hours") or 0) * 60)
|
||||
config.notes = request.form.get("notes") or None
|
||||
db.session.commit()
|
||||
flash("Objectif annuel et déduction de pénibilité enregistrés.", "success")
|
||||
return _redirect(year)
|
||||
|
||||
|
||||
@planning_bp.route("/time/template", methods=["POST"])
|
||||
@login_required
|
||||
def save_time_template():
|
||||
count = WorkScheduleTemplate.query.filter_by(user_id=current_user.id, is_active=True).count()
|
||||
if count >= 3:
|
||||
flash("Vous pouvez définir au maximum trois horaires types.", "warning")
|
||||
return _redirect()
|
||||
start, end = _time(request.form.get("start_time")), _time(request.form.get("end_time"))
|
||||
lunch_start, lunch_end = _time(request.form.get("lunch_start")), _time(request.form.get("lunch_end"))
|
||||
if _minutes(start, end, lunch_start, lunch_end) <= 0:
|
||||
flash("Les horaires saisis sont invalides.", "danger")
|
||||
return _redirect()
|
||||
template = WorkScheduleTemplate(
|
||||
user_id=current_user.id, name=(request.form.get("name") or f"Horaire {count + 1}").strip(),
|
||||
start_time=start, end_time=end, lunch_start=lunch_start, lunch_end=lunch_end,
|
||||
)
|
||||
db.session.add(template)
|
||||
db.session.commit()
|
||||
flash("Horaire type ajouté.", "success")
|
||||
return _redirect()
|
||||
|
||||
|
||||
@planning_bp.route("/time/template/<int:id>/delete", methods=["POST"])
|
||||
@login_required
|
||||
def delete_time_template(id):
|
||||
template = WorkScheduleTemplate.query.filter_by(id=id, user_id=current_user.id).first_or_404()
|
||||
template.is_active = False
|
||||
for assignment in template.assignments:
|
||||
assignment.template_id = None
|
||||
db.session.commit()
|
||||
flash("Horaire type désactivé.", "success")
|
||||
return _redirect()
|
||||
|
||||
|
||||
@planning_bp.route("/time/template/<int:id>/edit", methods=["POST"])
|
||||
@login_required
|
||||
def edit_time_template(id):
|
||||
template = WorkScheduleTemplate.query.filter_by(id=id, user_id=current_user.id, is_active=True).first_or_404()
|
||||
start, end = _time(request.form.get("start_time")), _time(request.form.get("end_time"))
|
||||
lunch_start = _time(request.form.get("lunch_start"))
|
||||
lunch_end = _time(request.form.get("lunch_end")) or template.lunch_end
|
||||
if _minutes(start, end, lunch_start, lunch_end) <= 0:
|
||||
flash("Les horaires saisis sont invalides.", "danger")
|
||||
return _redirect()
|
||||
template.name = (request.form.get("name") or template.name).strip()
|
||||
template.start_time, template.end_time = start, end
|
||||
template.lunch_start, template.lunch_end = lunch_start, lunch_end
|
||||
for assignment in template.assignments:
|
||||
assignment.start_time, assignment.end_time = start, end
|
||||
assignment.lunch_start, assignment.lunch_end = lunch_start, lunch_end
|
||||
db.session.commit()
|
||||
flash("Horaire type modifié.", "success")
|
||||
return _redirect()
|
||||
|
||||
|
||||
@planning_bp.route("/time/assignment", methods=["POST"])
|
||||
@login_required
|
||||
def assign_time_template():
|
||||
day = request.form.get("day_of_week", type=int)
|
||||
template_id = request.form.get("template_id", type=int)
|
||||
assignment = WorkSchedule.query.filter_by(user_id=current_user.id, day_of_week=day).first()
|
||||
if not template_id:
|
||||
if assignment:
|
||||
db.session.delete(assignment)
|
||||
else:
|
||||
template = WorkScheduleTemplate.query.filter_by(id=template_id, user_id=current_user.id, is_active=True).first_or_404()
|
||||
if not assignment:
|
||||
assignment = WorkSchedule(user_id=current_user.id, day_of_week=day)
|
||||
db.session.add(assignment)
|
||||
assignment.template_id = template.id
|
||||
assignment.start_time, assignment.end_time = template.start_time, template.end_time
|
||||
assignment.lunch_start, assignment.lunch_end = template.lunch_start, template.lunch_end
|
||||
assignment.is_active = True
|
||||
db.session.commit()
|
||||
return _redirect()
|
||||
|
||||
|
||||
@planning_bp.route("/time/entry", methods=["POST"])
|
||||
@login_required
|
||||
def save_time_entry():
|
||||
work_date = datetime.strptime(request.form["work_date"], "%Y-%m-%d").date()
|
||||
entry = TimeEntry.query.filter_by(user_id=current_user.id, work_date=work_date).first()
|
||||
if not entry:
|
||||
entry = TimeEntry(user_id=current_user.id, work_date=work_date)
|
||||
db.session.add(entry)
|
||||
entry.planned_minutes = int(request.form.get("planned_minutes") or 0)
|
||||
entry.actual_minutes = int(request.form.get("actual_minutes") or 0)
|
||||
entry.entry_type = request.form.get("entry_type") or "travail"
|
||||
entry.note = request.form.get("note") or None
|
||||
db.session.commit()
|
||||
flash("Suivi de la journée enregistré.", "success")
|
||||
return _redirect(work_date.year)
|
||||
|
||||
|
||||
@planning_bp.route("/time/import-vacations", methods=["POST"])
|
||||
@login_required
|
||||
def import_school_vacations():
|
||||
from app_new.lib_ext.vacances_scolaires import import_vacances_scolaires
|
||||
year = request.form.get("year", date.today().year, type=int)
|
||||
added, ignored, errors = import_vacances_scolaires(request.form.get("zone", "B"), year, current_user.id)
|
||||
flash(f"Vacances importées : {added} ajoutée(s), {ignored} déjà présente(s)." + (f" {len(errors)} erreur(s)." if errors else ""), "warning" if errors else "success")
|
||||
return _redirect(year)
|
||||
|
||||
|
||||
@planning_bp.route("/time/import-holidays", methods=["POST"])
|
||||
@login_required
|
||||
def import_public_holidays():
|
||||
from app_new.lib_ext.vacances_scolaires import import_jours_feries
|
||||
year = request.form.get("year", date.today().year, type=int)
|
||||
added, ignored = import_jours_feries(year, current_user.id)
|
||||
flash(f"Jours fériés importés : {added} ajouté(s), {ignored} déjà présent(s).", "success")
|
||||
return _redirect(year)
|
||||
|
||||
|
||||
@planning_bp.route("/time/permanence", methods=["POST"])
|
||||
@login_required
|
||||
def add_permanence():
|
||||
work_date = datetime.strptime(request.form["work_date"], "%Y-%m-%d").date()
|
||||
closure = CollegeClosure.query.filter(
|
||||
CollegeClosure.start_date <= work_date, CollegeClosure.end_date >= work_date,
|
||||
CollegeClosure.closure_type.in_(["vacances", "vacances_scolaires"]),
|
||||
).first()
|
||||
if not closure:
|
||||
flash("La date doit se situer dans une période de vacances scolaires.", "danger")
|
||||
return _redirect(work_date.year)
|
||||
day = ClosureWorkDay.query.filter_by(closure_id=closure.id, work_date=work_date).first()
|
||||
if not day:
|
||||
day = ClosureWorkDay(closure_id=closure.id, work_date=work_date)
|
||||
db.session.add(day)
|
||||
day.start_time, day.end_time = _time(request.form.get("start_time")), _time(request.form.get("end_time"))
|
||||
day.lunch_start, day.lunch_end = _time(request.form.get("lunch_start")), _time(request.form.get("lunch_end"))
|
||||
day.notes = request.form.get("notes") or "Permanence"
|
||||
closure.work_hours_type = "custom"
|
||||
db.session.commit()
|
||||
flash("Permanence enregistrée.", "success")
|
||||
return _redirect(work_date.year)
|
||||
|
||||
|
||||
@planning_bp.route("/time/permanence/<int:id>/delete", methods=["POST"])
|
||||
@login_required
|
||||
def delete_permanence(id):
|
||||
day = ClosureWorkDay.query.get_or_404(id)
|
||||
year = day.work_date.year
|
||||
db.session.delete(day)
|
||||
db.session.commit()
|
||||
flash("Permanence supprimée.", "success")
|
||||
return _redirect(year)
|
||||
|
|
@ -188,6 +188,7 @@
|
|||
<li><a class="dropdown-item" href="{{ url_for('planning.meters') }}"><i class="bi bi-speedometer2"></i> Compteurs</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('planning.consumables') }}"><i class="bi bi-box"></i> Consommables</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('planning.availability') }}"><i class="bi bi-clock"></i> Disponibilités</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('planning.time_tracking') }}"><i class="bi bi-clock-history"></i> Horaires et suivi annuel</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('planning.admin_tasks') }}"><i class="bi bi-clipboard"></i> Tâches administratives</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('planning.rules') }}"><i class="bi bi-shield-check"></i> Règles d'accès</a></li>
|
||||
<li><hr class="dropdown-divider" style="border-color:rgba(255,255,255,0.15)"></li>
|
||||
|
|
|
|||
65
migrations/versions/f8b2c3d4e5f6_add_annual_time_tracking.py
Normal file
65
migrations/versions/f8b2c3d4e5f6_add_annual_time_tracking.py
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
"""Ajoute les profils horaires et le suivi annuel du temps."""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "f8b2c3d4e5f6"
|
||||
down_revision = ("a4b8c2d6e0f1", "f1a5b6c7d8e9", "f7a1b2c3d4e5")
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
"work_schedule_templates",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False),
|
||||
sa.Column("name", sa.String(80), nullable=False),
|
||||
sa.Column("start_time", sa.Time(), nullable=False),
|
||||
sa.Column("end_time", sa.Time(), nullable=False),
|
||||
sa.Column("lunch_start", sa.Time()),
|
||||
sa.Column("lunch_end", sa.Time()),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
)
|
||||
op.create_index("ix_work_schedule_templates_user_id", "work_schedule_templates", ["user_id"])
|
||||
op.add_column("work_schedules", sa.Column("template_id", sa.Integer(), nullable=True))
|
||||
op.create_foreign_key("fk_work_schedules_template_id", "work_schedules", "work_schedule_templates", ["template_id"], ["id"])
|
||||
|
||||
op.create_table(
|
||||
"annual_time_configs",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False),
|
||||
sa.Column("year", sa.Integer(), nullable=False),
|
||||
sa.Column("target_minutes", sa.Integer(), nullable=False, server_default="96420"),
|
||||
sa.Column("hardship_deduction_minutes", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("notes", sa.Text()),
|
||||
sa.UniqueConstraint("user_id", "year", name="uq_annual_time_user_year"),
|
||||
)
|
||||
op.create_index("ix_annual_time_configs_user_id", "annual_time_configs", ["user_id"])
|
||||
|
||||
op.create_table(
|
||||
"work_time_entries",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False),
|
||||
sa.Column("work_date", sa.Date(), nullable=False),
|
||||
sa.Column("planned_minutes", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("actual_minutes", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("entry_type", sa.String(30), nullable=False, server_default="travail"),
|
||||
sa.Column("note", sa.Text()),
|
||||
sa.Column("created_at", sa.DateTime()),
|
||||
sa.Column("updated_at", sa.DateTime()),
|
||||
sa.UniqueConstraint("user_id", "work_date", name="uq_work_time_user_date"),
|
||||
)
|
||||
op.create_index("ix_work_time_entries_user_id", "work_time_entries", ["user_id"])
|
||||
op.create_index("ix_work_time_entries_work_date", "work_time_entries", ["work_date"])
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_index("ix_work_time_entries_work_date", table_name="work_time_entries")
|
||||
op.drop_index("ix_work_time_entries_user_id", table_name="work_time_entries")
|
||||
op.drop_table("work_time_entries")
|
||||
op.drop_index("ix_annual_time_configs_user_id", table_name="annual_time_configs")
|
||||
op.drop_table("annual_time_configs")
|
||||
op.drop_constraint("fk_work_schedules_template_id", "work_schedules", type_="foreignkey")
|
||||
op.drop_column("work_schedules", "template_id")
|
||||
op.drop_index("ix_work_schedule_templates_user_id", table_name="work_schedule_templates")
|
||||
op.drop_table("work_schedule_templates")
|
||||
Loading…
Reference in a new issue