Ajouter le récapitulatif mensuel des horaires
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
6b56817dbb
commit
b1c3c95d5c
2 changed files with 66 additions and 2 deletions
|
|
@ -14,6 +14,8 @@
|
|||
<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((variance_minutes|abs) // 60, (variance_minutes|abs) % 60) }}</h3><small>{% if variance_minutes >= 0 %}heures à récupérer{% else %}heures à faire{% endif %}</small></div></div></div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3"><div class="card-header"><strong>Récapitulatif mensuel</strong><small class="d-block text-muted">Les heures de travail prévues excluent les permanences, affichées séparément.</small></div><div class="table-responsive"><table class="table table-sm align-middle mb-0"><thead><tr><th>Mois</th><th>Travail prévu</th><th>Permanences</th><th>Retards</th><th>Heures supplémentaires</th><th>Formations</th></tr></thead><tbody>{% for item in monthly_summary %}<tr><td><strong>{{ months[item.month.month] }} {{ item.month.year }}</strong></td><td>{{ '%d h %02d'|format(item.planned // 60, item.planned % 60) }}</td><td>{{ '%d h %02d'|format(item.permanence // 60, item.permanence % 60) }}</td><td class="{% if item.retard %}text-danger{% endif %}">{{ '%d h %02d'|format(item.retard // 60, item.retard % 60) }}</td><td class="{% if item.supplementaire %}text-success{% endif %}">{{ '%d h %02d'|format(item.supplementaire // 60, item.supplementaire % 60) }}</td><td>{{ '%d h %02d'|format(item.formation // 60, item.formation % 60) }}</td></tr>{% else %}<tr><td colspan="6" class="text-muted">Aucune donnée pour cette période.</td></tr>{% endfor %}</tbody></table></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">
|
||||
|
|
@ -36,7 +38,10 @@
|
|||
|
||||
<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="C" selected>Zone C</option><option value="B">Zone B</option><option value="A">Zone A</option></select><button class="btn btn-outline-primary">Importer vacances</button></div><small class="text-muted">Changer de zone remplace les périodes scolaires importées pour l’autre zone.</small></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>Permanences dans les vacances</strong><small class="d-block text-muted">Sélectionnez plusieurs dates puis l’horaire type à appliquer.</small></div><div class="card-body"><form method="post" action="{{ url_for('planning.save_permanences') }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><input type="hidden" name="year" value="{{ year }}"><div class="row g-2 align-items-end"><div class="col-md-8"><label class="form-label">Horaire type</label><select class="form-select" name="template_id" required><option value="">Choisir…</option>{% for template in templates %}<option value="{{ template.id }}">{{ template.name }} ({{ template.start_time.strftime('%H:%M') }}–{{ template.end_time.strftime('%H:%M') }})</option>{% endfor %}</select></div><div class="col-md-4"><button class="btn btn-success w-100" {% if not templates %}disabled{% endif %}>Affecter les dates sélectionnées</button></div></div><div class="row g-2 mt-2">{% for option in permanence_options %}<div class="col-xl-3 col-lg-4 col-md-6"><label class="border rounded p-2 d-block"><input type="checkbox" name="work_dates" value="{{ option.date.isoformat() }}" {% if option.entry %}checked{% endif %}> <strong>{{ option.date.strftime('%d/%m/%Y') }}</strong><br><small>{{ option.closure.name }}{% if option.entry and option.entry.template %} · {{ option.entry.template.name }}{% endif %}</small></label></div>{% else %}<div class="col-12 text-muted">Aucune période de vacances importée pour cette période. Importez d’abord le calendrier scolaire.</div>{% endfor %}</div></form></div></div>
|
||||
<div class="card mb-3"><div class="card-header"><strong>Permanences dans les vacances</strong><small class="d-block text-muted">Sélectionnez plusieurs dates puis l’horaire type à appliquer.</small></div><div class="card-body"><form method="post" action="{{ url_for('planning.save_permanences') }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><input type="hidden" name="year" value="{{ year }}"><div class="row g-2 align-items-end"><div class="col-md-8"><label class="form-label">Horaire type</label><select class="form-select" name="template_id" required><option value="">Choisir…</option>{% for template in templates %}<option value="{{ template.id }}">{{ template.name }} ({{ template.start_time.strftime('%H:%M') }}–{{ template.end_time.strftime('%H:%M') }})</option>{% endfor %}</select></div><div class="col-md-4"><button class="btn btn-success w-100" {% if not templates %}disabled{% endif %}>Affecter les dates sélectionnées</button></div></div><div class="row g-2 mt-2">{% for option in permanence_options %}<div class="col-xl-3 col-lg-4 col-md-6"><label class="border rounded p-2 d-block"><input type="checkbox" name="work_dates" value="{{ option.date.isoformat() }}" {% if option.entry %}checked{% endif %}> <strong>{{ days.get(option.date.weekday()) }} {{ option.date.strftime('%d/%m/%Y') }}</strong><br><small>{{ option.closure.name }}{% if option.entry and option.entry.template %} · {{ option.entry.template.name }} · {{ option.entry.start_time.strftime('%H:%M') }}–{{ option.entry.end_time.strftime('%H:%M') }}{% endif %}</small></label></div>{% else %}<div class="col-12 text-muted">Aucune période de vacances importée pour cette période. Importez d’abord le calendrier scolaire.</div>{% endfor %}</div></form>
|
||||
{% set selected_permanences = permanence_options|selectattr('entry')|list %}
|
||||
<hr><h6><i class="bi bi-list-check me-1"></i>Récapitulatif des permanences</h6>
|
||||
{% if selected_permanences %}<div class="table-responsive"><table class="table table-sm align-middle mb-0"><thead><tr><th>Date</th><th>Vacances</th><th>Horaire type</th><th>Horaires détaillés</th><th></th></tr></thead><tbody>{% for option in selected_permanences %}<tr><td><strong>{{ days.get(option.date.weekday()) }}</strong><br>{{ option.date.strftime('%d/%m/%Y') }}</td><td>{{ option.closure.name }}</td><td>{{ option.entry.template.name if option.entry.template else 'Horaire personnalisé' }}</td><td>{{ option.entry.start_time.strftime('%H:%M') }}–{{ option.entry.end_time.strftime('%H:%M') }}{% if option.entry.lunch_start and option.entry.lunch_end %}<br><small class="text-muted">Pause {{ option.entry.lunch_start.strftime('%H:%M') }}–{{ option.entry.lunch_end.strftime('%H:%M') }}</small>{% endif %}</td><td><form method="post" action="{{ url_for('planning.delete_permanence', id=option.entry.id) }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="btn btn-sm btn-outline-danger" title="Supprimer"><i class="bi bi-trash"></i></button></form></td></tr>{% endfor %}</tbody></table></div>{% else %}<p class="text-muted mb-0">Aucune permanence enregistrée pour cette période.</p>{% endif %}</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>
|
||||
|
|
|
|||
|
|
@ -39,6 +39,16 @@ def _period(year):
|
|||
return date(year, 9, 1), date(year + 1, 8, 31)
|
||||
|
||||
|
||||
def _month_starts(start, end):
|
||||
"""Retourne les premiers jours de chaque mois de la période comptable."""
|
||||
current = date(start.year, start.month, 1)
|
||||
result = []
|
||||
while current <= end:
|
||||
result.append(current)
|
||||
current = date(current.year + (current.month == 12), 1 if current.month == 12 else current.month + 1, 1)
|
||||
return result
|
||||
|
||||
|
||||
@planning_bp.route("/time")
|
||||
@login_required
|
||||
def time_tracking():
|
||||
|
|
@ -69,6 +79,14 @@ def time_tracking():
|
|||
TimeEntry.work_date >= period_start, TimeEntry.work_date <= period_end,
|
||||
).order_by(TimeEntry.work_date.desc()).all()
|
||||
planned = 0
|
||||
planned_minutes_by_month = {}
|
||||
permanence_minutes_by_month = {}
|
||||
for work_day in work_days:
|
||||
if period_start <= work_day.work_date <= period_end:
|
||||
month = date(work_day.work_date.year, work_day.work_date.month, 1)
|
||||
permanence_minutes_by_month[month] = permanence_minutes_by_month.get(month, 0) + _minutes(
|
||||
work_day.start_time, work_day.end_time, work_day.lunch_start, work_day.lunch_end
|
||||
)
|
||||
current = period_start
|
||||
while current <= period_end:
|
||||
hours = None
|
||||
|
|
@ -89,7 +107,10 @@ def time_tracking():
|
|||
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)
|
||||
day_minutes = _minutes(*hours)
|
||||
planned += day_minutes
|
||||
month = date(current.year, current.month, 1)
|
||||
planned_minutes_by_month[month] = planned_minutes_by_month.get(month, 0) + day_minutes
|
||||
current += timedelta(days=1)
|
||||
permanence_options = []
|
||||
for closure in closures:
|
||||
|
|
@ -103,12 +124,50 @@ def time_tracking():
|
|||
permanence_options.append({"date": current_day, "closure": closure, "entry": existing})
|
||||
current_day += timedelta(days=1)
|
||||
actual = sum(item.actual_minutes or 0 for item in entries)
|
||||
# Synthèse mensuelle : les permanences sont isolées du temps de travail
|
||||
# planifié habituel, et les formations viennent des convocations validées.
|
||||
monthly_summary = []
|
||||
trainings = Training.query.join(TrainingParticipant).filter(
|
||||
TrainingParticipant.user_id == current_user.id,
|
||||
TrainingParticipant.is_confirmed.is_(True),
|
||||
Training.end_date >= period_start,
|
||||
Training.start_date <= period_end,
|
||||
).all()
|
||||
for month in _month_starts(period_start, period_end):
|
||||
month_end = date(month.year + (month.month == 12), 1 if month.month == 12 else month.month + 1, 1) - timedelta(days=1)
|
||||
month_entries = [item for item in entries if month <= item.work_date <= month_end]
|
||||
training_minutes = 0
|
||||
for training in trainings:
|
||||
start = max(training.start_date or month, month, period_start)
|
||||
end = min(training.end_date or start, month_end, period_end)
|
||||
if end < start:
|
||||
continue
|
||||
day = start
|
||||
while day <= end:
|
||||
if day.weekday() < 5:
|
||||
if training.start_time and training.end_time:
|
||||
training_minutes += _minutes(training.start_time, training.end_time)
|
||||
elif assignments.get(day.weekday()):
|
||||
assignment = assignments[day.weekday()]
|
||||
source = assignment.template if assignment.template and assignment.template.is_active else assignment
|
||||
training_minutes += _minutes(source.start_time, source.end_time, source.lunch_start, source.lunch_end)
|
||||
day += timedelta(days=1)
|
||||
monthly_summary.append({
|
||||
"month": month,
|
||||
"planned": max(planned_minutes_by_month.get(month, 0) - permanence_minutes_by_month.get(month, 0), 0),
|
||||
"permanence": permanence_minutes_by_month.get(month, 0),
|
||||
"retard": sum(item.actual_minutes or 0 for item in month_entries if item.entry_type == "retard"),
|
||||
"supplementaire": sum(item.actual_minutes or 0 for item in month_entries if item.entry_type == "supplementaire"),
|
||||
"formation": training_minutes,
|
||||
})
|
||||
return render_template(
|
||||
"planning/time_tracking.html", year=year, period_start=period_start, period_end=period_end, time_config=config, templates=templates,
|
||||
assignments=assignments, closures=closures, permanence_options=permanence_options, entries=entries,
|
||||
days={0: "Lundi", 1: "Mardi", 2: "Mercredi", 3: "Jeudi", 4: "Vendredi", 5: "Samedi", 6: "Dimanche"},
|
||||
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"},
|
||||
planned_minutes=planned, actual_minutes=actual,
|
||||
required_minutes=config.minutes_to_work, variance_minutes=actual - config.minutes_to_work,
|
||||
monthly_summary=monthly_summary,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue