Initial commit — décompte horaire
App web de suivi des heures de travail (FastAPI + HTMX + JSON plats). - Saisie inline par semaine avec bouton ✓ par ligne - Congés MA/AM/J avec coloration cellule et mise à jour live via OOB HTMX - Import fichier ODS badgeuse - Solde semaine et solde global recalculés à la volée - Design "ledger" écru, DM Mono, zéro border-radius - Déploiement Docker sur port 8000 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
74
app/templates/_ligne.html
Normal file
74
app/templates/_ligne.html
Normal file
@ -0,0 +1,74 @@
|
||||
<tr id="row-{{ j.date }}">
|
||||
|
||||
<td class="col-jour">
|
||||
<span class="jour-nom">{{ nom }}</span>
|
||||
<span class="jour-date">{{ j.date[8:] }}/{{ j.date[5:7] }}</span>
|
||||
</td>
|
||||
|
||||
<td class="c-matin">
|
||||
<div id="cg-matin-{{ j.date }}" class="td-fill{% if 'matin' in j.conge or 'jour' in j.conge %} cg{% endif %}">
|
||||
<div class="time-pair">
|
||||
<input class="time-input" type="time" name="matin_entree"
|
||||
data-date="{{ j.date }}" value="{{ j.matin_entree or '' }}">
|
||||
<span class="time-sep">→</span>
|
||||
<input class="time-input" type="time" name="matin_sortie"
|
||||
data-date="{{ j.date }}" value="{{ j.matin_sortie or '' }}">
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td class="c-aprem">
|
||||
<div id="cg-aprem-{{ j.date }}" class="td-fill{% if 'aprem' in j.conge or 'jour' in j.conge %} cg{% endif %}">
|
||||
<div class="time-pair">
|
||||
<input class="time-input" type="time" name="aprem_entree"
|
||||
data-date="{{ j.date }}" value="{{ j.aprem_entree or '' }}">
|
||||
<span class="time-sep">→</span>
|
||||
<input class="time-input" type="time" name="aprem_sortie"
|
||||
data-date="{{ j.date }}" value="{{ j.aprem_sortie or '' }}">
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td class="col-calc">
|
||||
<span id="calc-{{ j.date }}-trav">
|
||||
<span class="calc-num">{{ j.travaille if j.travaille_min else '—' }}</span>
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td class="col-calc">
|
||||
<span id="calc-{{ j.date }}-delta">
|
||||
<span class="{% if j.delta_min > 0 %}delta-pos{% elif j.delta_min < 0 %}delta-neg{% else %}delta-zero{% endif %}">
|
||||
{%- if j.travaille_min or j.du_min -%}
|
||||
{% if j.delta_min > 0 %}+{% endif %}{{ j.delta }}
|
||||
{%- else %}—{%- endif %}
|
||||
</span>
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td class="col-save">
|
||||
<button class="btn-valider"
|
||||
hx-post="/pointage/{{ j.date }}"
|
||||
hx-swap="none"
|
||||
hx-include="[data-date='{{ j.date }}']"
|
||||
title="Enregistrer">✓</button>
|
||||
</td>
|
||||
|
||||
<td class="col-conge">
|
||||
<div id="cg-btn-{{ j.date }}">
|
||||
<div class="conge-btns">
|
||||
<button hx-post="/conge/{{ j.date }}/matin"
|
||||
hx-swap="none"
|
||||
class="btn-cg {% if 'matin' in j.conge %}on{% endif %}"
|
||||
title="Congé matin">MA</button>
|
||||
<button hx-post="/conge/{{ j.date }}/aprem"
|
||||
hx-swap="none"
|
||||
class="btn-cg {% if 'aprem' in j.conge %}on{% endif %}"
|
||||
title="Congé après-midi">AM</button>
|
||||
<button hx-post="/conge/{{ j.date }}/jour"
|
||||
hx-swap="none"
|
||||
class="btn-cg btn-jour {% if 'jour' in j.conge %}jour-on{% endif %}"
|
||||
title="Journée entière">J</button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
24
app/templates/_soldes.html
Normal file
24
app/templates/_soldes.html
Normal file
@ -0,0 +1,24 @@
|
||||
<div id="soldes-section" hx-swap-oob="true">
|
||||
<div class="soldes">
|
||||
<div class="solde-item">
|
||||
<div class="solde-eyebrow">Travaillé</div>
|
||||
<div class="solde-num neutral">{{ result.total_travaille }}</div>
|
||||
</div>
|
||||
<div class="solde-item">
|
||||
<div class="solde-eyebrow">Dû cette semaine</div>
|
||||
<div class="solde-num neutral">{{ result.total_du }}</div>
|
||||
</div>
|
||||
<div class="solde-item {% if result.solde_min > 0 %}item-pos{% elif result.solde_min < 0 %}item-neg{% endif %}">
|
||||
<div class="solde-eyebrow">Solde semaine</div>
|
||||
<div class="solde-num {% if result.solde_min > 0 %}pos{% elif result.solde_min < 0 %}neg{% endif %}">
|
||||
{% if result.solde_min > 0 %}+{% endif %}{{ result.solde }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="solde-item {% if solde_global_min > 0 %}item-pos{% elif solde_global_min < 0 %}item-neg{% endif %}">
|
||||
<div class="solde-eyebrow">Solde global</div>
|
||||
<div class="solde-num {% if solde_global_min > 0 %}pos{% elif solde_global_min < 0 %}neg{% endif %}">
|
||||
{% if solde_global_min > 0 %}+{% endif %}{{ solde_global }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
515
app/templates/base.html
Normal file
515
app/templates/base.html
Normal file
@ -0,0 +1,515 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Décompte Horaire</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@300;400;500&family=DM+Sans:opsz,wght@9..40,300;9..40,400;9..40,500;9..40,600&display=swap" rel="stylesheet">
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--ground: #F4F0E8;
|
||||
--surface: #FDFAF4;
|
||||
--text: #1C1712;
|
||||
--muted: #6B6152;
|
||||
--rule: #D8D0C0;
|
||||
--rule-l: #EAE5DA;
|
||||
--accent: #2F5FA0;
|
||||
--accent-2: #B86A08;
|
||||
--pos: #2A5E42;
|
||||
--pos-bg: #EBF5EE;
|
||||
--neg: #9B3528;
|
||||
--neg-bg: #F9EDEB;
|
||||
--conge-bg: #FEF6E4;
|
||||
--conge-bd: #E8C97A;
|
||||
--conge-txt: #7A5300;
|
||||
--nav-bg: #1C1712;
|
||||
--mono: 'DM Mono', 'Cascadia Code', ui-monospace, monospace;
|
||||
--sans: 'DM Sans', system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--sans);
|
||||
background: var(--ground);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ── Nav ── */
|
||||
nav {
|
||||
background: var(--nav-bg);
|
||||
color: var(--ground);
|
||||
height: 50px;
|
||||
padding: 0 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
border-bottom: 3px solid var(--accent-2);
|
||||
}
|
||||
.nav-brand {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--ground);
|
||||
text-decoration: none;
|
||||
letter-spacing: .1em;
|
||||
text-transform: uppercase;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .55rem;
|
||||
}
|
||||
.nav-dot {
|
||||
width: 7px; height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-2);
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
nav .spacer { flex: 1; }
|
||||
nav a.nav-link {
|
||||
color: rgba(244,240,232,.55);
|
||||
text-decoration: none;
|
||||
font-size: 12px;
|
||||
letter-spacing: .02em;
|
||||
transition: color .15s;
|
||||
}
|
||||
nav a.nav-link:hover { color: var(--ground); }
|
||||
|
||||
/* ── Layout ── */
|
||||
.container {
|
||||
max-width: 940px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.75rem;
|
||||
}
|
||||
|
||||
/* ── Week header ── */
|
||||
.week-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
border-bottom: 2px solid var(--text);
|
||||
padding-bottom: .75rem;
|
||||
}
|
||||
.week-nav-arrows { display: flex; gap: .4rem; }
|
||||
.arrow-btn {
|
||||
width: 28px; height: 28px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
border: 1.5px solid var(--rule);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
transition: border-color .15s, color .15s;
|
||||
}
|
||||
.arrow-btn:hover { border-color: var(--text); color: var(--text); }
|
||||
.week-id {
|
||||
font-family: var(--mono);
|
||||
font-size: 1.75rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: -.02em;
|
||||
line-height: 1;
|
||||
}
|
||||
.week-id .wk { color: var(--muted); font-size: 1rem; font-weight: 300; }
|
||||
.week-id .slash { color: var(--rule); margin: 0 .2em; }
|
||||
|
||||
/* ── Soldes ── */
|
||||
.soldes {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
border: 1.5px solid var(--rule);
|
||||
background: var(--surface);
|
||||
}
|
||||
@media (max-width: 600px) { .soldes { grid-template-columns: 1fr 1fr; } }
|
||||
.solde-item {
|
||||
padding: 1rem 1.25rem;
|
||||
border-right: 1px solid var(--rule);
|
||||
}
|
||||
.solde-item:last-child { border-right: none; }
|
||||
.solde-item.item-pos { background: var(--pos-bg); }
|
||||
.solde-item.item-neg { background: var(--neg-bg); }
|
||||
.solde-eyebrow {
|
||||
font-family: var(--mono);
|
||||
font-size: 9px;
|
||||
letter-spacing: .12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
margin-bottom: .45rem;
|
||||
}
|
||||
.solde-num {
|
||||
font-family: var(--mono);
|
||||
font-size: 1.9rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: -.03em;
|
||||
line-height: 1;
|
||||
}
|
||||
.solde-num.pos { color: var(--pos); }
|
||||
.solde-num.neg { color: var(--neg); }
|
||||
.solde-num.neutral { color: var(--text); }
|
||||
|
||||
/* ── Table ── */
|
||||
.table-section { overflow-x: auto; }
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border: 1.5px solid var(--rule);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.th-group th {
|
||||
padding: .45rem .85rem .25rem;
|
||||
font-family: var(--mono);
|
||||
font-size: 9px;
|
||||
letter-spacing: .12em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 500;
|
||||
color: var(--muted);
|
||||
border-bottom: 1px solid var(--rule-l);
|
||||
background: var(--ground);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.th-group .g-matin {
|
||||
background: rgba(47,95,160,.07);
|
||||
color: var(--accent);
|
||||
border-left: 3px solid var(--accent);
|
||||
text-align: center;
|
||||
}
|
||||
.th-group .g-aprem {
|
||||
background: rgba(184,106,8,.07);
|
||||
color: var(--accent-2);
|
||||
border-left: 3px solid var(--accent-2);
|
||||
text-align: center;
|
||||
}
|
||||
.th-group .g-calc { text-align: right; }
|
||||
.th-group .g-conge { text-align: center; }
|
||||
|
||||
.th-sub th {
|
||||
padding: .25rem .85rem .5rem;
|
||||
font-size: 10px;
|
||||
color: rgba(107,97,82,.65);
|
||||
border-bottom: 2px solid var(--text);
|
||||
font-weight: 400;
|
||||
background: var(--ground);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.th-sub .s-matin {
|
||||
background: rgba(47,95,160,.04);
|
||||
border-left: 3px solid var(--accent);
|
||||
color: rgba(47,95,160,.75);
|
||||
text-align: center;
|
||||
font-family: var(--mono);
|
||||
font-size: 9px;
|
||||
letter-spacing: .04em;
|
||||
}
|
||||
.th-sub .s-aprem {
|
||||
background: rgba(184,106,8,.04);
|
||||
border-left: 3px solid var(--accent-2);
|
||||
color: rgba(184,106,8,.85);
|
||||
text-align: center;
|
||||
font-family: var(--mono);
|
||||
font-size: 9px;
|
||||
letter-spacing: .04em;
|
||||
}
|
||||
.th-sub .s-calc { text-align: right; }
|
||||
.th-sub .s-conge { text-align: center; }
|
||||
|
||||
tbody tr { transition: background .1s; }
|
||||
tbody tr:hover td { background: rgba(47,95,160,.025); }
|
||||
tbody tr:hover .td-fill { background: rgba(47,95,160,.025); }
|
||||
tbody tr:hover .td-fill.cg { background: var(--conge-bg); }
|
||||
tbody tr:not(:last-child) td { border-bottom: 1px solid var(--rule-l); }
|
||||
|
||||
td {
|
||||
padding: .6rem .85rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Matin / aprem cells: padding moved to inner .td-fill so OOB can target it */
|
||||
td.c-matin, td.c-aprem { padding: 0; }
|
||||
.td-fill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: .6rem .85rem;
|
||||
}
|
||||
td.c-matin .td-fill { border-left: 3px solid rgba(47,95,160,.2); }
|
||||
td.c-aprem .td-fill { border-left: 3px solid rgba(184,106,8,.2); }
|
||||
|
||||
/* Congé state — applied to the inner div, not the <td> */
|
||||
.td-fill.cg {
|
||||
background: var(--conge-bg);
|
||||
}
|
||||
td.c-matin .td-fill.cg,
|
||||
td.c-aprem .td-fill.cg { border-left-color: var(--conge-bd); }
|
||||
.td-fill.cg .time-input { background: var(--conge-bg); }
|
||||
|
||||
.col-jour { white-space: nowrap; }
|
||||
.jour-nom { display: block; font-weight: 500; font-size: 13px; }
|
||||
.jour-date { display: block; font-family: var(--mono); font-size: 11px; color: var(--muted); }
|
||||
|
||||
.time-pair { display: flex; align-items: center; gap: 4px; justify-content: center; }
|
||||
.time-sep { color: var(--rule); font-size: 11px; flex-shrink: 0; }
|
||||
.time-input {
|
||||
width: 80px;
|
||||
padding: .28rem .4rem;
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 0;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
background: transparent;
|
||||
text-align: center;
|
||||
transition: border-color .15s;
|
||||
}
|
||||
.time-input:hover { border-color: var(--muted); }
|
||||
.time-input:focus { outline: none; border-color: var(--accent); background: #fff; }
|
||||
tr.htmx-request .time-input { opacity: .4; }
|
||||
|
||||
.col-calc { text-align: right; white-space: nowrap; width: 72px; }
|
||||
.calc-num { font-family: var(--mono); font-size: 13px; color: var(--muted); }
|
||||
.delta-pos { font-family: var(--mono); font-size: 13px; font-weight: 500; color: var(--pos); }
|
||||
.delta-neg { font-family: var(--mono); font-size: 13px; font-weight: 500; color: var(--neg); }
|
||||
.delta-zero{ font-family: var(--mono); font-size: 13px; color: var(--rule); }
|
||||
|
||||
.col-save { width: 36px; text-align: center; padding: .4rem .3rem; }
|
||||
.g-save { width: 36px; background: var(--ground); }
|
||||
.btn-valider {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px; height: 26px;
|
||||
border: 1.5px solid var(--accent);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--accent);
|
||||
transition: background .15s, color .15s;
|
||||
}
|
||||
.btn-valider:hover { background: var(--accent); color: #fff; }
|
||||
.btn-valider.htmx-request { opacity: .4; pointer-events: none; }
|
||||
.col-conge { width: 88px; text-align: center; }
|
||||
.conge-btns { display: flex; gap: 3px; justify-content: center; }
|
||||
.btn-cg {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px; height: 24px;
|
||||
border: 1.5px solid var(--rule);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-family: var(--mono);
|
||||
font-size: 9px;
|
||||
font-weight: 500;
|
||||
letter-spacing: .04em;
|
||||
color: var(--muted);
|
||||
transition: all .15s;
|
||||
}
|
||||
.btn-cg:hover { border-color: var(--conge-txt); color: var(--conge-txt); background: var(--conge-bg); }
|
||||
.btn-cg.on { background: var(--conge-bg); border-color: var(--conge-bd); color: var(--conge-txt); }
|
||||
.btn-cg.btn-jour { border-radius: 50%; width: 22px; height: 22px; font-size: 9px; }
|
||||
.btn-cg.jour-on { background: var(--accent-2); border-color: #955505; color: #fff; }
|
||||
|
||||
/* ── Historique ── */
|
||||
.history {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .75rem;
|
||||
border-top: 1px solid var(--rule);
|
||||
padding-top: 1.25rem;
|
||||
}
|
||||
.hist-label {
|
||||
font-family: var(--mono);
|
||||
font-size: 9px;
|
||||
letter-spacing: .12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
.hist-body { display: flex; flex-direction: column; gap: .6rem; }
|
||||
.hist-year-group { display: flex; align-items: center; gap: .75rem; flex-wrap: wrap; }
|
||||
.hist-year {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--muted);
|
||||
width: 36px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.hist-chips { display: flex; flex-wrap: wrap; gap: .3rem; }
|
||||
.chip {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
padding: .18rem .5rem;
|
||||
border: 1px solid var(--rule);
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
letter-spacing: .02em;
|
||||
transition: all .15s;
|
||||
}
|
||||
.chip:hover { border-color: var(--text); color: var(--text); }
|
||||
.chip.here { background: var(--text); border-color: var(--text); color: var(--ground); }
|
||||
|
||||
/* ── Import ── */
|
||||
.import-wrap { padding: 2rem; }
|
||||
.import-wrap h2 { font-size: 1rem; font-weight: 600; margin-bottom: .75rem; border-bottom: 1px solid var(--rule); padding-bottom: .5rem; }
|
||||
.import-desc { color: var(--muted); font-size: 13px; margin-bottom: 1.25rem; line-height: 1.65; }
|
||||
.file-input { display: block; margin-bottom: 1rem; font-size: 13px; }
|
||||
.btn-submit {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: .4rem;
|
||||
padding: .5rem 1.1rem;
|
||||
border: 1.5px solid var(--text);
|
||||
background: var(--text);
|
||||
color: var(--ground);
|
||||
font-size: 12px;
|
||||
font-family: var(--mono);
|
||||
font-weight: 500;
|
||||
letter-spacing: .05em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
transition: background .15s;
|
||||
}
|
||||
.btn-submit:hover { background: #33271E; border-color: #33271E; }
|
||||
.msg-ok { padding: .6rem 1rem; border-left: 3px solid var(--pos); background: var(--pos-bg); color: var(--pos); font-size: 13px; margin-bottom: 1rem; }
|
||||
.msg-err { padding: .6rem 1rem; border-left: 3px solid var(--neg); background: var(--neg-bg); color: var(--neg); font-size: 13px; margin-bottom: 1rem; }
|
||||
|
||||
/* ── Card shell (import page) ── */
|
||||
.card { background: var(--surface); border: 1.5px solid var(--rule); }
|
||||
|
||||
/* ── Calendrier sélecteur ── */
|
||||
.cal-trigger-wrap { position: relative; }
|
||||
|
||||
.cal-trigger {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: .1em;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-family: var(--mono);
|
||||
font-size: 1.75rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: -.02em;
|
||||
color: var(--text);
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
.cal-trigger .wk { color: var(--muted); font-size: 1rem; font-weight: 300; }
|
||||
.cal-trigger .slash { color: var(--rule); margin: 0 .2em; }
|
||||
.cal-trigger .cal-caret {
|
||||
font-size: .85rem;
|
||||
color: var(--muted);
|
||||
margin-left: .35em;
|
||||
transition: transform .15s;
|
||||
}
|
||||
.cal-trigger[aria-expanded="true"] .cal-caret { transform: rotate(180deg); }
|
||||
.cal-trigger:hover .cal-caret { color: var(--text); }
|
||||
|
||||
.cal-popup {
|
||||
position: absolute;
|
||||
top: calc(100% + .5rem);
|
||||
left: 0;
|
||||
z-index: 100;
|
||||
background: var(--surface);
|
||||
border: 1.5px solid var(--rule);
|
||||
box-shadow: 0 4px 20px rgba(28,23,18,.12);
|
||||
padding: .75rem;
|
||||
min-width: 260px;
|
||||
}
|
||||
.cal-popup[hidden] { display: none; }
|
||||
|
||||
.cal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: .6rem;
|
||||
}
|
||||
.cal-month-label {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
letter-spacing: .04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text);
|
||||
}
|
||||
.cal-nav {
|
||||
background: none;
|
||||
border: 1px solid var(--rule);
|
||||
width: 24px; height: 24px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: border-color .15s, color .15s;
|
||||
}
|
||||
.cal-nav:hover { border-color: var(--text); color: var(--text); }
|
||||
|
||||
.cal-grid { width: 100%; border-collapse: collapse; }
|
||||
.cal-grid thead th {
|
||||
font-family: var(--mono);
|
||||
font-size: 9px;
|
||||
letter-spacing: .1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
padding: .2rem .3rem;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
}
|
||||
.cal-grid thead th.cal-wn {
|
||||
color: var(--accent);
|
||||
border-right: 1px solid var(--rule-l);
|
||||
}
|
||||
.cal-grid thead th.cal-we { color: var(--neg); }
|
||||
|
||||
.cal-grid .cal-row td {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
padding: .3rem .25rem;
|
||||
color: var(--text);
|
||||
transition: background .1s;
|
||||
}
|
||||
.cal-grid .cal-row:hover td { background: var(--ground); }
|
||||
.cal-grid .cal-row:hover td.cal-wn { background: rgba(47,95,160,.1); }
|
||||
|
||||
.cal-grid .cal-row td.cal-wn {
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
font-size: 11px;
|
||||
border-right: 1px solid var(--rule-l);
|
||||
padding-right: .4rem;
|
||||
}
|
||||
.cal-grid .cal-row td.cal-out { color: var(--rule); }
|
||||
.cal-grid .cal-row td.cal-we { color: var(--muted); }
|
||||
|
||||
.cal-grid .cal-current td {
|
||||
background: var(--ground);
|
||||
font-weight: 500;
|
||||
}
|
||||
.cal-grid .cal-current td.cal-wn {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
.cal-grid .cal-current:hover td { background: rgba(47,95,160,.06); }
|
||||
.cal-grid .cal-current:hover td.cal-wn { background: var(--accent); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
<a href="/" class="nav-brand"><span class="nav-dot"></span>Décompte Horaire</a>
|
||||
<span class="spacer"></span>
|
||||
<a href="/" class="nav-link">Semaine courante</a>
|
||||
<a href="/import" class="nav-link">Importer ODS</a>
|
||||
</nav>
|
||||
<div class="container">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
23
app/templates/import.html
Normal file
23
app/templates/import.html
Normal file
@ -0,0 +1,23 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
|
||||
<div class="card import-wrap">
|
||||
<h2>Importer un fichier ODS</h2>
|
||||
{% if msg %}
|
||||
{% if 'Erreur' in msg %}
|
||||
<div class="msg-err">{{ msg }}</div>
|
||||
{% else %}
|
||||
<div class="msg-ok">✓ {{ msg }}</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<p class="import-desc">
|
||||
Sélectionne le fichier exporté par la badgeuse.<br>
|
||||
Les données existantes sont mises à jour — les congés ne sont pas affectés.
|
||||
</p>
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
<input class="file-input" type="file" name="fichier" accept=".ods" required>
|
||||
<button type="submit" class="btn-submit">Importer</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
205
app/templates/semaine.html
Normal file
205
app/templates/semaine.html
Normal file
@ -0,0 +1,205 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
|
||||
<div class="week-header">
|
||||
<div class="week-nav-arrows">
|
||||
<a href="/semaine/{{ prev_year }}/{{ prev_week }}" class="arrow-btn">←</a>
|
||||
<a href="/semaine/{{ next_year }}/{{ next_week }}" class="arrow-btn">→</a>
|
||||
</div>
|
||||
|
||||
<!-- Sélecteur calendrier -->
|
||||
<div class="cal-trigger-wrap">
|
||||
<button class="cal-trigger" id="cal-trigger" aria-expanded="false"
|
||||
onclick="toggleCal()">
|
||||
<span class="wk">S</span>{{ week }}<span class="slash">/</span>{{ year }}
|
||||
<span class="cal-caret">▾</span>
|
||||
</button>
|
||||
<div class="cal-popup" id="cal-popup" hidden>
|
||||
<div class="cal-header">
|
||||
<button class="cal-nav" onclick="calPrev()">←</button>
|
||||
<span class="cal-month-label" id="cal-month-label"></span>
|
||||
<button class="cal-nav" onclick="calNext()">→</button>
|
||||
</div>
|
||||
<table class="cal-grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="cal-wn">S</th>
|
||||
<th>L</th><th>M</th><th>M</th><th>J</th><th>V</th>
|
||||
<th class="cal-we">S</th><th class="cal-we">D</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="cal-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Soldes -->
|
||||
{% include "_soldes.html" %}
|
||||
|
||||
<!-- Tableau -->
|
||||
<div class="table-section">
|
||||
<table>
|
||||
<thead>
|
||||
<tr class="th-group">
|
||||
<th class="g-jour" rowspan="2">Jour</th>
|
||||
<th class="g-matin">Matin</th>
|
||||
<th class="g-aprem">Après-midi</th>
|
||||
<th class="g-calc" rowspan="2">Travaillé</th>
|
||||
<th class="g-calc" rowspan="2">Delta</th>
|
||||
<th class="g-save" rowspan="2"></th>
|
||||
<th class="g-conge" rowspan="2">Congé</th>
|
||||
</tr>
|
||||
<tr class="th-sub">
|
||||
<th class="s-matin">entrée → sortie</th>
|
||||
<th class="s-aprem">entrée → sortie</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for j, nom in result.jours | zip(jours_fr) %}
|
||||
{% include "_ligne.html" %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Historique groupé par année -->
|
||||
{% if weeks_by_year %}
|
||||
<div class="history">
|
||||
<span class="hist-label">Historique</span>
|
||||
<div class="hist-body">
|
||||
{% for yr, wks in weeks_by_year.items() %}
|
||||
<div class="hist-year-group">
|
||||
<span class="hist-year">{{ yr }}</span>
|
||||
<div class="hist-chips">
|
||||
{% for yw in wks %}
|
||||
<a href="/semaine/{{ yw[0] }}/{{ yw[1] }}"
|
||||
class="chip{% if yw[0] == year and yw[1] == week %} here{% endif %}">
|
||||
S{{ '%02d' % yw[1] }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const CURRENT_YEAR = {{ year }};
|
||||
const CURRENT_WEEK = {{ week }};
|
||||
|
||||
// ISO week number from a Date
|
||||
function isoWeek(d) {
|
||||
const date = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
|
||||
// Thursday of this week determines the ISO year
|
||||
date.setUTCDate(date.getUTCDate() + 4 - (date.getUTCDay() || 7));
|
||||
const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
|
||||
return {
|
||||
week: Math.ceil((((date - yearStart) / 86400000) + 1) / 7),
|
||||
year: date.getUTCFullYear()
|
||||
};
|
||||
}
|
||||
|
||||
// Monday of an ISO week
|
||||
function mondayOfISOWeek(year, week) {
|
||||
const simple = new Date(Date.UTC(year, 0, 1 + (week - 1) * 7));
|
||||
const dow = simple.getUTCDay() || 7; // Mon=1..Sun=7
|
||||
if (dow <= 4) simple.setUTCDate(simple.getUTCDate() - dow + 1);
|
||||
else simple.setUTCDate(simple.getUTCDate() + 8 - dow);
|
||||
return simple;
|
||||
}
|
||||
|
||||
const MONTHS_FR = ['Janvier','Février','Mars','Avril','Mai','Juin',
|
||||
'Juillet','Août','Septembre','Octobre','Novembre','Décembre'];
|
||||
|
||||
// Start calendar on the month containing the current week's Monday
|
||||
const initMonday = mondayOfISOWeek(CURRENT_YEAR, CURRENT_WEEK);
|
||||
let calYear = initMonday.getUTCFullYear();
|
||||
let calMonth = initMonday.getUTCMonth(); // 0-based
|
||||
|
||||
function renderCal() {
|
||||
document.getElementById('cal-month-label').textContent =
|
||||
MONTHS_FR[calMonth] + ' ' + calYear;
|
||||
|
||||
const body = document.getElementById('cal-body');
|
||||
body.innerHTML = '';
|
||||
|
||||
// First day of month (Mon-based grid)
|
||||
const first = new Date(Date.UTC(calYear, calMonth, 1));
|
||||
const startDow = (first.getUTCDay() || 7) - 1; // 0=Mon
|
||||
const startDate = new Date(first);
|
||||
startDate.setUTCDate(1 - startDow);
|
||||
|
||||
// 6 rows × 7 days
|
||||
for (let row = 0; row < 6; row++) {
|
||||
const monday = new Date(startDate);
|
||||
monday.setUTCDate(startDate.getUTCDate() + row * 7);
|
||||
|
||||
// Skip row if entirely outside ±1 month
|
||||
const firstOfRow = monday;
|
||||
const lastOfRow = new Date(monday); lastOfRow.setUTCDate(monday.getUTCDate() + 6);
|
||||
if (firstOfRow.getUTCMonth() > calMonth + 1 ||
|
||||
lastOfRow.getUTCMonth() < calMonth - 1) continue;
|
||||
|
||||
const { week: wn, year: wy } = isoWeek(monday);
|
||||
const isCurrent = (wy === CURRENT_YEAR && wn === CURRENT_WEEK);
|
||||
|
||||
const tr = document.createElement('tr');
|
||||
tr.className = 'cal-row' + (isCurrent ? ' cal-current' : '');
|
||||
tr.style.cursor = 'pointer';
|
||||
tr.title = `Semaine ${wn} / ${wy}`;
|
||||
tr.onclick = () => { window.location = `/semaine/${wy}/${wn}`; };
|
||||
|
||||
// Week number cell
|
||||
const wnTd = document.createElement('td');
|
||||
wnTd.className = 'cal-wn';
|
||||
wnTd.textContent = wn;
|
||||
tr.appendChild(wnTd);
|
||||
|
||||
for (let d = 0; d < 7; d++) {
|
||||
const day = new Date(monday); day.setUTCDate(monday.getUTCDate() + d);
|
||||
const td = document.createElement('td');
|
||||
td.textContent = day.getUTCDate();
|
||||
const inMonth = day.getUTCMonth() === calMonth;
|
||||
if (!inMonth) td.className = 'cal-out';
|
||||
if (d >= 5) td.className = (td.className ? td.className + ' ' : '') + 'cal-we';
|
||||
tr.appendChild(td);
|
||||
}
|
||||
body.appendChild(tr);
|
||||
}
|
||||
}
|
||||
|
||||
window.calPrev = () => {
|
||||
calMonth--;
|
||||
if (calMonth < 0) { calMonth = 11; calYear--; }
|
||||
renderCal();
|
||||
};
|
||||
window.calNext = () => {
|
||||
calMonth++;
|
||||
if (calMonth > 11) { calMonth = 0; calYear++; }
|
||||
renderCal();
|
||||
};
|
||||
|
||||
window.toggleCal = () => {
|
||||
const popup = document.getElementById('cal-popup');
|
||||
const btn = document.getElementById('cal-trigger');
|
||||
const open = popup.hidden;
|
||||
popup.hidden = !open;
|
||||
btn.setAttribute('aria-expanded', String(open));
|
||||
if (open) renderCal();
|
||||
};
|
||||
|
||||
// Close on outside click
|
||||
document.addEventListener('click', e => {
|
||||
const wrap = document.querySelector('.cal-trigger-wrap');
|
||||
if (wrap && !wrap.contains(e.target)) {
|
||||
document.getElementById('cal-popup').hidden = true;
|
||||
document.getElementById('cal-trigger').setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user