Un jour d'astreinte déclaré couvre désormais 18:00 → 18:00 le lendemain :
les interventions hors fenêtre ne comptent ni au décompte ni pour les
11h de repos, et deux astreintes consécutives se recollent sans trou.
La fin de la dernière intervention est arrondie au quart d'heure entamé
avant de poser le retour. Samedi et dimanche apparaissent dans la
semaine (heures dues nulles) pour pouvoir y déclarer des astreintes,
dans les exports aussi. Un réglage par utilisateur ajoute une durée
forfaitaire au temps travaillé de chaque jour d'astreinte déclaré.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
404 lines
15 KiB
HTML
404 lines
15 KiB
HTML
{% extends "base.html" %}
|
||
{% block content %}
|
||
<div class="stats-head">
|
||
<div>
|
||
<span class="stats-title">Réglages</span>
|
||
<div class="stats-meta">Rappels de pointage sur ton téléphone</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="settings-card">
|
||
<span class="hist-label">Plages horaires</span>
|
||
<p class="settings-p">
|
||
Bornes de présence obligatoire matin/après-midi et heure de reprise par défaut de
|
||
l'après-midi, utilisées pour les avertissements de saisie et les préconisations de
|
||
fin de demi-journée. Les heures visées (arrivée le matin, départ le soir) guident
|
||
les préconisations : le matin est planifié depuis ton arrivée visée, et le rattrapage
|
||
est casé avant ton départ visé tant que c'est possible. Propres à ton compte —
|
||
ne change rien pour les autres.
|
||
</p>
|
||
|
||
{% if plages_saved %}
|
||
<div class="settings-saved">Enregistré.</div>
|
||
{% endif %}
|
||
{% if plages_error %}
|
||
<div class="settings-error">Horaires invalides (format HH:MM, matin_debut < matin_fin < aprem_debut < aprem_fin).</div>
|
||
{% endif %}
|
||
|
||
<form method="post" action="/settings/plages" class="settings-form">
|
||
<label class="login-label" for="matin_debut">Présence matin</label>
|
||
<div class="settings-range">
|
||
<input id="matin_debut" class="login-input time-range-input" type="time" name="matin_debut" value="{{ plages.matin_debut }}">
|
||
<span class="settings-range-sep">→</span>
|
||
<input id="matin_fin" class="login-input time-range-input" type="time" name="matin_fin" value="{{ plages.matin_fin }}">
|
||
</div>
|
||
<label class="login-label" for="aprem_debut">Présence après-midi</label>
|
||
<div class="settings-range">
|
||
<input id="aprem_debut" class="login-input time-range-input" type="time" name="aprem_debut" value="{{ plages.aprem_debut }}">
|
||
<span class="settings-range-sep">→</span>
|
||
<input id="aprem_fin" class="login-input time-range-input" type="time" name="aprem_fin" value="{{ plages.aprem_fin }}">
|
||
</div>
|
||
<label class="login-label" for="pause_dejeuner_fin">Reprise par défaut de l'après-midi</label>
|
||
<input id="pause_dejeuner_fin" class="login-input time-range-input" type="time" name="pause_dejeuner_fin" value="{{ plages.pause_dejeuner_fin }}">
|
||
<label class="login-label" for="arrivee_visee">Heures visées arrivée / départ (optionnel)</label>
|
||
<div class="settings-range">
|
||
<input id="arrivee_visee" class="login-input time-range-input" type="time" name="arrivee_visee" value="{{ plages.arrivee_visee }}">
|
||
<span class="settings-range-sep">→</span>
|
||
<input id="depart_vise" class="login-input time-range-input" type="time" name="depart_vise" value="{{ plages.depart_vise }}" aria-label="Départ visé le soir">
|
||
</div>
|
||
<button class="login-btn" type="submit">Enregistrer</button>
|
||
</form>
|
||
</div>
|
||
|
||
<div class="settings-card">
|
||
<span class="hist-label">Ratios des heures d'astreinte</span>
|
||
<p class="settings-p">
|
||
Pondération des heures d'astreinte selon le type de jour et la fenêtre horaire :
|
||
le décompte se fait par tranches de 15 min (toute tranche entamée compte en
|
||
totalité) et chaque tranche est multipliée par le ratio de la première règle qui
|
||
la couvre. Une tranche hors règle compte ×1 (« Autre »). Règles globales à tous
|
||
les comptes, ajustables dans <code>data/config.json</code> (clé
|
||
<code>astreinte_facteurs</code>).
|
||
</p>
|
||
<div class="as-ratios">
|
||
{% for f in facteurs %}
|
||
<div class="as-ratio-row">
|
||
<span class="as-ratio-nom">{{ f.nom }}</span>
|
||
<span class="as-ratio-jours">{% for j in f.jours %}{{ {'semaine': 'Lun–Ven', 'sam': 'Samedi', 'dim': 'Dimanche', 'ferie': 'Férié'}[j] }}{% if not loop.last %} · {% endif %}{% endfor %}</span>
|
||
<span class="as-ratio-fenetre">{{ f.debut }} → {{ f.fin }}</span>
|
||
<span class="as-ratio-facteur">×{{ '%g' % f.facteur }}</span>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="settings-card">
|
||
<span class="hist-label">Forfait par jour d'astreinte</span>
|
||
<p class="settings-p">
|
||
Durée ajoutée automatiquement au temps travaillé de chaque jour d'astreinte
|
||
déclaré (bouton <b>AS</b>), en plus des interventions saisies. Elle compte dans
|
||
le bilan de la semaine ; le lendemain de l'astreinte n'est pas concerné.
|
||
Vide = pas de forfait.
|
||
</p>
|
||
|
||
{% if forfait_saved %}
|
||
<div class="settings-saved">Enregistré.</div>
|
||
{% endif %}
|
||
{% if forfait_error %}
|
||
<div class="settings-error">Durée invalide (format HH:MM, 12h00 maximum).</div>
|
||
{% endif %}
|
||
|
||
<form method="post" action="/settings/astreinte-forfait" class="settings-form">
|
||
<label class="login-label" for="astreinte_forfait">Durée ajoutée par jour d'astreinte</label>
|
||
<input id="astreinte_forfait" class="login-input time-range-input" type="time" step="900"
|
||
name="astreinte_forfait" value="{{ plages.astreinte_forfait }}">
|
||
<button class="login-btn" type="submit">Enregistrer</button>
|
||
</form>
|
||
</div>
|
||
|
||
<div class="settings-card">
|
||
<span class="hist-label">Notifications (ntfy) <a href="/aide" class="settings-help-link">Aide</a></span>
|
||
<p class="settings-p">
|
||
Crée un topic sur ton serveur <a href="https://ntfy.sh" target="_blank" rel="noopener">ntfy</a>
|
||
(public ou auto-hébergé), un identifiant secret que toi seul connais, abonne-toi dessus avec
|
||
l'app ntfy sur ton téléphone, puis renseigne les deux champs ci-dessous. Guide complet sur
|
||
la page <a href="/aide">Aide</a>.
|
||
</p>
|
||
|
||
{% if saved %}
|
||
<div class="settings-saved">Enregistré.</div>
|
||
{% endif %}
|
||
|
||
<form method="post" action="/settings" class="settings-form">
|
||
<label class="login-label" for="ntfy_server">Serveur ntfy</label>
|
||
<input
|
||
id="ntfy_server"
|
||
class="login-input"
|
||
type="text"
|
||
name="ntfy_server"
|
||
value="{{ ntfy_server }}"
|
||
placeholder="https://ntfy.sh"
|
||
autocomplete="off"
|
||
>
|
||
<label class="login-label" for="ntfy_topic">Topic ntfy</label>
|
||
<input
|
||
id="ntfy_topic"
|
||
class="login-input"
|
||
type="text"
|
||
name="ntfy_topic"
|
||
value="{{ ntfy_topic }}"
|
||
placeholder="ex: antoine-pointeuse-x7f2"
|
||
autocomplete="off"
|
||
>
|
||
<label class="login-label" for="ntfy_token">Jeton d'accès (optionnel, serveur protégé)</label>
|
||
<input
|
||
id="ntfy_token"
|
||
class="login-input"
|
||
type="password"
|
||
name="ntfy_token"
|
||
value="{{ ntfy_token }}"
|
||
placeholder="ex: tk_xxxxxxxxxxxxxxxx"
|
||
autocomplete="off"
|
||
>
|
||
<label class="login-label" for="rappel_debut_h">Plage horaire des rappels</label>
|
||
<div class="settings-range">
|
||
<input
|
||
id="rappel_debut_h"
|
||
class="login-input settings-range-input"
|
||
type="number" min="0" max="23" name="rappel_debut_h"
|
||
value="{{ rappel_debut_h }}"
|
||
>
|
||
<span class="settings-range-sep">h → </span>
|
||
<input
|
||
id="rappel_fin_h"
|
||
class="login-input settings-range-input"
|
||
type="number" min="1" max="24" name="rappel_fin_h"
|
||
value="{{ rappel_fin_h }}"
|
||
>
|
||
<span class="settings-range-sep">h, jours ouvrés</span>
|
||
</div>
|
||
<button class="login-btn" type="submit">Enregistrer</button>
|
||
</form>
|
||
<div class="settings-test-row">
|
||
<button
|
||
class="login-btn settings-test-btn"
|
||
hx-post="/settings/test-ntfy"
|
||
hx-target="#ntfy-test-result"
|
||
hx-swap="innerHTML"
|
||
hx-disabled-elt="this"
|
||
>Tester la notification</button>
|
||
<span id="ntfy-test-result"></span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="settings-card">
|
||
<span class="hist-label">Détection de présence (Automation) <a href="/aide" class="settings-help-link">Aide</a></span>
|
||
<p class="settings-p">
|
||
Crée une automatisation <a href="https://f-droid.org/en/packages/com.jens.automation2/" target="_blank" rel="noopener">Automation</a>
|
||
déclenchée par le Wi-Fi du bureau (ou une géofence) : à la <b>connexion</b>/entrée, appelle l'URL « arrivée » ;
|
||
à la <b>déconnexion</b>/sortie, l'URL « départ ». Tant que tu es détecté présent sans
|
||
avoir pointé l'entrée du matin, ou parti sans avoir pointé la sortie, tu reçois un rappel
|
||
selon l'intervalle réglé ci-dessus, dans la plage horaire configurée (jours ouvrés).
|
||
Étapes détaillées sur la page <a href="/aide">Aide</a>.
|
||
</p>
|
||
<div id="presence-urls">
|
||
<div class="settings-urlrow">
|
||
<span class="settings-urllabel">Arrivée</span>
|
||
<code class="settings-url" id="url-arrivee">{{ arrivee_url }}</code>
|
||
<button type="button" class="settings-copy-btn" data-copy-target="url-arrivee">Copier</button>
|
||
</div>
|
||
<div class="settings-urlrow">
|
||
<span class="settings-urllabel">Départ</span>
|
||
<code class="settings-url" id="url-depart">{{ depart_url }}</code>
|
||
<button type="button" class="settings-copy-btn" data-copy-target="url-depart">Copier</button>
|
||
</div>
|
||
</div>
|
||
<p class="settings-hint">
|
||
Ces URLs sont secrètes (elles contiennent ton token) — ne les partage pas.
|
||
En cas de compromission, régénère le token : les anciennes URLs cesseront de fonctionner
|
||
et tu devras mettre à jour Tasker avec les nouvelles.
|
||
</p>
|
||
<button
|
||
class="login-btn settings-regen-btn"
|
||
hx-post="/settings/regenerate-presence-token"
|
||
hx-target="#presence-urls"
|
||
hx-swap="outerHTML"
|
||
hx-confirm="Régénérer le token ? Les URLs actuelles cesseront immédiatement de fonctionner, tu devras mettre à jour Tasker."
|
||
hx-disabled-elt="this"
|
||
>Régénérer le token</button>
|
||
</div>
|
||
|
||
<style>
|
||
.settings-card {
|
||
background: var(--surface);
|
||
border: 1.5px solid var(--rule);
|
||
padding: 1.25rem 1.5rem;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: .75rem;
|
||
}
|
||
.settings-p { font-size: 13px; color: var(--text); line-height: 1.6; }
|
||
.settings-p a { color: var(--accent); }
|
||
.settings-p code {
|
||
font-family: var(--mono);
|
||
font-size: 12px;
|
||
background: var(--ground);
|
||
border: 1px solid var(--rule-l);
|
||
padding: 0 .25rem;
|
||
}
|
||
.as-ratios { border: 1px solid var(--rule-l); display: flex; flex-direction: column; }
|
||
.as-ratio-row {
|
||
display: grid;
|
||
grid-template-columns: minmax(110px, auto) 1fr auto auto;
|
||
gap: .5rem 1rem;
|
||
align-items: baseline;
|
||
padding: .45rem .8rem;
|
||
}
|
||
.as-ratio-row + .as-ratio-row { border-top: 1px solid var(--rule-l); }
|
||
.as-ratio-nom { font-size: 13px; font-weight: 500; }
|
||
.as-ratio-jours { font-size: 12px; color: var(--muted); }
|
||
.as-ratio-fenetre { font-family: var(--mono); font-size: 12px; color: var(--muted); }
|
||
.as-ratio-facteur { font-family: var(--mono); font-size: 13px; font-weight: 500; color: var(--accent-2); }
|
||
@media (max-width: 600px) {
|
||
.as-ratio-row { grid-template-columns: 1fr auto; row-gap: .1rem; }
|
||
.as-ratio-jours, .as-ratio-fenetre { grid-column: 1 / -1; }
|
||
}
|
||
.settings-form { display: flex; flex-direction: column; gap: .5rem; max-width: 420px; }
|
||
.settings-saved {
|
||
background: var(--pos-bg);
|
||
border-left: 3px solid var(--pos);
|
||
color: var(--pos);
|
||
font-size: 12px;
|
||
padding: .5rem .8rem;
|
||
font-family: var(--mono);
|
||
}
|
||
.settings-error {
|
||
background: var(--neg-bg);
|
||
border-left: 3px solid var(--neg);
|
||
color: var(--neg);
|
||
font-size: 12px;
|
||
padding: .5rem .8rem;
|
||
font-family: var(--mono);
|
||
}
|
||
.settings-urlrow { display: flex; align-items: center; gap: .75rem; flex-wrap: wrap; }
|
||
.settings-urllabel {
|
||
font-family: var(--mono);
|
||
font-size: 10px;
|
||
letter-spacing: .08em;
|
||
text-transform: uppercase;
|
||
color: var(--muted);
|
||
width: 70px;
|
||
flex-shrink: 0;
|
||
}
|
||
.settings-url {
|
||
font-family: var(--mono);
|
||
font-size: 12px;
|
||
color: var(--text);
|
||
background: var(--ground);
|
||
border: 1px solid var(--rule);
|
||
padding: .4rem .6rem;
|
||
word-break: break-all;
|
||
}
|
||
.settings-hint { font-size: 11px; color: var(--muted); }
|
||
.settings-range { display: flex; align-items: center; gap: .4rem; flex-wrap: wrap; }
|
||
.settings-range-input { width: 70px; text-align: center; }
|
||
.settings-range-sep { font-size: 12px; color: var(--muted); font-family: var(--mono); }
|
||
.time-range-input {
|
||
width: 128px;
|
||
text-align: left;
|
||
padding-right: .4rem;
|
||
}
|
||
.time-range-input::-webkit-calendar-picker-indicator {
|
||
margin-left: .5rem;
|
||
}
|
||
.settings-help-link {
|
||
font-family: var(--mono);
|
||
font-size: 10px;
|
||
letter-spacing: .04em;
|
||
color: var(--accent);
|
||
text-decoration: none;
|
||
border: 1px solid var(--rule);
|
||
padding: .1rem .4rem;
|
||
margin-left: .5rem;
|
||
vertical-align: middle;
|
||
transition: border-color .15s, background .15s;
|
||
}
|
||
.settings-help-link:hover {
|
||
border-color: var(--accent);
|
||
background: rgba(47,95,160,.06);
|
||
}
|
||
.settings-test-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: .75rem;
|
||
flex-wrap: wrap;
|
||
}
|
||
.settings-test-btn {
|
||
flex-shrink: 0;
|
||
}
|
||
.settings-regen-btn {
|
||
align-self: flex-start;
|
||
border-color: var(--neg);
|
||
color: var(--neg);
|
||
}
|
||
.settings-regen-btn:hover:not([disabled]) {
|
||
background: var(--neg-bg);
|
||
}
|
||
.settings-copy-btn {
|
||
font-family: var(--mono);
|
||
font-size: 10px;
|
||
letter-spacing: .04em;
|
||
color: var(--accent);
|
||
background: none;
|
||
border: 1px solid var(--rule);
|
||
padding: .35rem .6rem;
|
||
cursor: pointer;
|
||
flex-shrink: 0;
|
||
transition: border-color .15s, background .15s;
|
||
}
|
||
.settings-copy-btn:hover {
|
||
border-color: var(--accent);
|
||
background: rgba(47,95,160,.06);
|
||
}
|
||
.settings-copy-btn.settings-copy-btn--done {
|
||
color: var(--pos);
|
||
border-color: var(--pos);
|
||
}
|
||
#ntfy-test-result {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
#ntfy-test-result:empty { display: none; }
|
||
#ntfy-test-result:not(:empty) {
|
||
font-family: var(--mono);
|
||
font-size: 12px;
|
||
padding: .5rem .8rem;
|
||
border-left: 3px solid var(--muted);
|
||
background: var(--ground);
|
||
line-height: 1.45;
|
||
}
|
||
</style>
|
||
<script>
|
||
(function () {
|
||
// Délégation sur document : #presence-urls est remplacé par un swap OOB
|
||
// htmx quand le token est régénéré, les boutons "Copier" doivent continuer
|
||
// à fonctionner après ce remplacement.
|
||
document.addEventListener("click", function (e) {
|
||
var btn = e.target.closest(".settings-copy-btn");
|
||
if (!btn) return;
|
||
var target = document.getElementById(btn.dataset.copyTarget);
|
||
if (!target) return;
|
||
var text = target.textContent;
|
||
|
||
var onDone = function (ok) {
|
||
var previous = btn.textContent;
|
||
btn.textContent = ok ? "Copié !" : "Échec";
|
||
btn.classList.toggle("settings-copy-btn--done", ok);
|
||
setTimeout(function () {
|
||
btn.textContent = previous;
|
||
btn.classList.remove("settings-copy-btn--done");
|
||
}, 1500);
|
||
};
|
||
|
||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||
navigator.clipboard.writeText(text).then(
|
||
function () { onDone(true); },
|
||
function () { onDone(false); }
|
||
);
|
||
} else {
|
||
var textarea = document.createElement("textarea");
|
||
textarea.value = text;
|
||
textarea.style.position = "fixed";
|
||
textarea.style.opacity = "0";
|
||
document.body.appendChild(textarea);
|
||
textarea.select();
|
||
var ok = false;
|
||
try { ok = document.execCommand("copy"); } catch (err) { ok = false; }
|
||
document.body.removeChild(textarea);
|
||
onDone(ok);
|
||
}
|
||
});
|
||
})();
|
||
</script>
|
||
{% endblock %}
|