feat(absences): worker detail tab + dashboard alert + CLAUDE.md (Round D)
#5 from checkpoint feedback: /workers/<id>/ now has an Absences tab showing YTD totals (chip row) + 50 most-recent absences (table). Admin dashboard adds a conditional 'X absent in last 7 days' alert card (only renders when count > 0; links to filtered /absences/). CLAUDE.md gets a new Absence model entry + URL routes + dedicated 'Absence-to-PayrollAdjustment cascade' section. Reason-badge CSS moved to static/css/custom.css as single source of truth. 4 new tests.
This commit is contained in:
parent
8c749f3f52
commit
9345dacfbf
39
CLAUDE.md
39
CLAUDE.md
@ -70,6 +70,7 @@ staticfiles/ — Collected static assets (Bootstrap, admin) — NOT in git (
|
||||
- **WorkerCertificate** — per-worker certifications (Skills, PDP, First Aid, Medical, Work at Height) with `valid_until` expiry and optional document upload. Unique per (worker, cert_type). Has `is_expired` and `expires_soon` (≤30 days) properties.
|
||||
- **WorkerWarning** — disciplinary records per worker with severity (verbal/written/final), reason, optional document. Ordered -date.
|
||||
- **SiteReport** — optional 1:1 with `WorkLog`, captures what was DONE on site that day: weather, temperature_min/max (°C, IntegerField), free-form `notes`, and a flexible `metrics` JSONField with shape `{'counts': {key: int}, 'checks': {key: bool}}`. The metric KEYS live in `core/site_report_schema.py` (NOT in the model) — see the "SiteReport metric schema" section below for the rationale + how to add a new metric without a migration. Reverse accessor: `work_log.site_report` (1:1, raises DoesNotExist when absent — wrap with try/except or use `WorkLog.objects.filter(site_report__isnull=False)`).
|
||||
- **Absence** — per-worker dated record of a day not worked. 8 reason choices (Sick, Family Responsibility, Annual Leave, Personal/Unpaid Leave, Injury on Duty, Suspension, Absconded, Other). Optional `project` FK (SET_NULL). `is_paid` boolean (default False) — when ticked, the save flow auto-creates a Bonus PayrollAdjustment via `_sync_absence_payroll_adjustment(absence)` helper, inheriting the absence's project for cost-attribution. Linked via OneToOneField (`payroll_adjustment`). Unique per (worker, date) at DB layer. Permission scoping: admin (all) or supervisor (workers in their teams).
|
||||
|
||||
### Schema name-drifts to remember
|
||||
Fields / accessors that differ from what you'd guess. Each has bitten multiple
|
||||
@ -176,6 +177,38 @@ log. The form has a "Skip" link to home — site reports are entirely
|
||||
optional. WorkLogs without a SiteReport are completely valid historic
|
||||
rows; they just don't show progress data on `/history/`.
|
||||
|
||||
## Absence-to-PayrollAdjustment cascade (May 2026)
|
||||
|
||||
`Absence.is_paid=True` auto-creates a Bonus PayrollAdjustment at
|
||||
`worker.daily_rate`, inheriting `absence.project` for cost attribution.
|
||||
Linked via `Absence.payroll_adjustment` OneToOneField. Logic lives in
|
||||
`_sync_absence_payroll_adjustment(absence)` in `core/views.py` —
|
||||
called from `absence_log`, `absence_log_confirm`, `absence_edit`, and
|
||||
any future quick-action save path. Wrapped in `transaction.atomic()`
|
||||
to prevent orphaned adjustments on partial failure.
|
||||
|
||||
Edit / delete cascades:
|
||||
- Toggle `is_paid` True → False → adjustment is deleted; refuses
|
||||
(raises ValueError) if adjustment is already paid (`payroll_record`
|
||||
is set). Caller surfaces this as a messages.error to admin.
|
||||
- Toggle `is_paid` False → True → fresh Bonus adjustment created.
|
||||
- Toggle `is_paid` True → True (re-save while paid) → adjustment is
|
||||
LEFT ALONE (admin may have manually edited the amount; we don't
|
||||
second-guess). See `test_paid_with_existing_adj_is_idempotent`.
|
||||
- Delete of Absence cascades to delete the unpaid linked adjustment.
|
||||
If the adjustment is already paid, the delete is refused with a
|
||||
messages.error.
|
||||
|
||||
The "Submit + Log Absences" button on `/attendance/log/` lets admins
|
||||
jump from logging attendance straight to `/absences/log/` pre-filled
|
||||
with the same date, team, and project. Uses `next_action=log_absences`
|
||||
POST param; default Submit keeps the existing SiteReport redirect.
|
||||
|
||||
Permission scoping helper: `_absence_user_queryset(user)` in `core/views.py`
|
||||
is the single authority for "which absences can this user see/touch". Admin
|
||||
sees all; supervisor sees absences for workers in any team they supervise
|
||||
(`worker__teams__supervisor=user`).
|
||||
|
||||
## Payroll Constants
|
||||
Defined at top of views.py — used in dashboard calculations and payment processing:
|
||||
- **ADDITIVE_TYPES** = `['Bonus', 'Overtime', 'New Loan', 'Advance Payment']` — increase worker's net pay
|
||||
@ -312,6 +345,12 @@ numbers on hot pages.
|
||||
| `/history/export/` | `export_work_log_csv` | Download filtered logs as CSV |
|
||||
| `/site-report/<work_log_id>/edit/` | `site_report_edit` | Create-or-update the optional SiteReport for a WorkLog (auto-redirected here after `/attendance/log/` POST) |
|
||||
| `/site-report/<work_log_id>/` | `site_report_detail` | Read-only view of the SiteReport (404 if none — use the edit URL to create) |
|
||||
| `/absences/log/` | `absence_log` | Admin/supervisor: log absences (date range, multi-worker). Reads `date`, `team`, `project` GET params for prefill from /attendance/log/'s "Submit + Log Absences" shortcut. |
|
||||
| `/absences/log/confirm/` | `absence_log_confirm` | Yellow conflict-warning page; per-row Remove-from-WorkLog checkboxes; reads pending data from session. |
|
||||
| `/absences/` | `absence_list` | Filtered list with pagination. Multi-select reason filter (`?reason=sick&reason=iod` etc.). Direct `project_id` filter. |
|
||||
| `/absences/<id>/edit/` | `absence_edit` | Edit one absence; syncs PayrollAdjustment on is_paid toggle. |
|
||||
| `/absences/<id>/delete/` | `absence_delete` | POST-only; cascades unpaid adjustment; refuses if paid. |
|
||||
| `/absences/export/` | `absence_export_csv` | Admin-only CSV; honors all list filters. |
|
||||
| `/workers/export/` | `export_workers_csv` | Admin: export all workers to CSV |
|
||||
| `/workers/` | `worker_list` | Admin: friendly worker list with search + status filter |
|
||||
| `/workers/new/` | `worker_edit` | Admin: blank worker-create form |
|
||||
|
||||
@ -139,7 +139,7 @@ has an inline delete form. CSV export button only shows for admin.
|
||||
<td><span class="badge badge-absence-{{ a.reason }}">{{ a.get_reason_display }}</span></td>
|
||||
<td>
|
||||
{% if a.is_paid %}
|
||||
<i class="fas fa-check-circle" style="color: var(--badge-bonus-bg);"></i>
|
||||
<i class="fas fa-check-circle text-success"></i>
|
||||
{% if a.payroll_adjustment %}<small class="text-muted">({{ a.payroll_adjustment.amount|money }})</small>{% endif %}
|
||||
{% else %}
|
||||
<i class="far fa-circle text-muted"></i>
|
||||
@ -184,22 +184,7 @@ has an inline delete form. CSV export button only shows for admin.
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% comment %}
|
||||
=== Reason badge colours ===
|
||||
Reuses the existing semantic badge palette from custom.css so dark/
|
||||
light theme switching works out of the box. Green-ish for "valid"
|
||||
leave (sick/family/annual), neutral for unpaid/other, amber for IOD,
|
||||
purple-ish (deduction) for the disciplinary reasons (suspension,
|
||||
absconded).
|
||||
{% endcomment %}
|
||||
<style>
|
||||
.badge-absence-sick { background: var(--badge-bonus-bg); color: var(--badge-bonus-fg); }
|
||||
.badge-absence-family { background: var(--badge-bonus-bg); color: var(--badge-bonus-fg); }
|
||||
.badge-absence-annual { background: var(--badge-bonus-bg); color: var(--badge-bonus-fg); }
|
||||
.badge-absence-unpaid { background: var(--badge-neutral-bg, #6c757d); color: #fff; }
|
||||
.badge-absence-iod { background: var(--badge-overtime-bg, #ffc107); color: var(--badge-overtime-fg, #000); }
|
||||
.badge-absence-suspension { background: var(--badge-deduction-bg); color: var(--badge-deduction-fg); }
|
||||
.badge-absence-absconded { background: var(--badge-deduction-bg); color: var(--badge-deduction-fg); }
|
||||
.badge-absence-other { background: var(--badge-neutral-bg, #6c757d); color: #fff; }
|
||||
</style>
|
||||
{# Reason badge colours live in static/css/custom.css under #}
|
||||
{# "/* === Absence reason badges === */" — single source of truth shared #}
|
||||
{# between this list and the Absences tab on /workers/<id>/. #}
|
||||
{% endblock %}
|
||||
|
||||
@ -96,6 +96,28 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Absences in last 7 days — shown ONLY when count > 0.
|
||||
Same conditional pattern as the cert-expiry card below. Clicks through
|
||||
to the Absences list filtered to the last 7 days. -->
|
||||
{% if absences_recent_count %}
|
||||
<div class="col-xl-3 col-md-6">
|
||||
<a href="{% url 'absence_list' %}?date_from={{ seven_days_ago|date:'Y-m-d' }}" class="stat-card stat-card--danger h-100 p-3 d-block" style="text-decoration: none; color: inherit;">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<div class="stat-label">Absences (last 7 days)</div>
|
||||
<div class="stat-value" style="font-size: 1.5rem;">{{ absences_recent_count }}</div>
|
||||
<div style="font-size: 0.75rem; margin-top: 0.35rem; color: var(--text-secondary);">
|
||||
absent in last 7 days
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-icon stat-icon--danger">
|
||||
<i class="fas fa-user-clock"></i>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Certifications Expiring — shown ONLY when count > 0
|
||||
Clicking it goes to the Worker Batch Report which shows per-worker cert columns. -->
|
||||
{% if certs_alert_total %}
|
||||
|
||||
@ -48,6 +48,7 @@
|
||||
<li class="nav-item"><button class="nav-link active" data-bs-toggle="tab" data-bs-target="#profile" type="button">Profile</button></li>
|
||||
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#certs" type="button">Certifications <span class="badge bg-secondary ms-1">{{ certs.count }}</span></button></li>
|
||||
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#warnings" type="button">Warnings <span class="badge bg-secondary ms-1">{{ warnings.count }}</span></button></li>
|
||||
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#absences" type="button"><i class="fas fa-user-clock me-1"></i>Absences <span class="badge bg-secondary ms-1">{{ absence_total_count }}</span></button></li>
|
||||
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#history" type="button">History</button></li>
|
||||
</ul>
|
||||
|
||||
@ -236,6 +237,80 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- === ABSENCES TAB === -->
|
||||
{# Shows year-to-date totals as colored chips at the top, then a table #}
|
||||
{# of the 50 most-recent absences. "Log Absence" button pre-fills today.#}
|
||||
<div class="tab-pane fade" id="absences">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h6 class="text-uppercase mb-0" style="font-size: 0.75rem; color: var(--text-secondary);">Year-to-date</h6>
|
||||
<a href="{% url 'absence_log' %}?date={{ today|date:'Y-m-d' }}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="fas fa-plus me-1"></i>Log Absence
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{# YTD chip row — one badge per reason that has at least one #}
|
||||
{# absence this calendar year. Hidden entirely if zero. #}
|
||||
<div class="d-flex flex-wrap gap-2 mb-3">
|
||||
{% for key, label in absence_reason_choices %}
|
||||
{% if absence_ytd_totals|dictlookup:key %}
|
||||
<span class="badge badge-absence-{{ key }}">
|
||||
{{ label }}: {{ absence_ytd_totals|dictlookup:key }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if not absence_ytd_totals %}
|
||||
<small class="text-muted">No absences this year.</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if worker_absences %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Reason</th>
|
||||
<th>Project</th>
|
||||
<th>Paid?</th>
|
||||
<th>Notes</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for a in worker_absences %}
|
||||
<tr>
|
||||
<td>{{ a.date|date:"d M Y" }}</td>
|
||||
<td><span class="badge badge-absence-{{ a.reason }}">{{ a.get_reason_display }}</span></td>
|
||||
<td>{{ a.project.name|default:"—" }}</td>
|
||||
<td>
|
||||
{% if a.is_paid %}
|
||||
<i class="fas fa-check-circle text-success"></i>
|
||||
{% else %}
|
||||
<i class="far fa-circle text-muted"></i>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="color: var(--text-secondary); font-size: 0.85rem;">{{ a.notes|truncatechars:50 }}</td>
|
||||
<td>
|
||||
<a href="{% url 'absence_edit' a.id %}" class="btn btn-sm btn-outline-secondary" title="Edit">
|
||||
<i class="fas fa-pen"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-center py-4 mb-0" style="color: var(--text-secondary);">
|
||||
No absences recorded for this worker yet.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- === HISTORY TAB === -->
|
||||
<div class="tab-pane fade" id="history">
|
||||
<div class="row g-3">
|
||||
|
||||
@ -2710,3 +2710,79 @@ class AbsenceAttendanceShortcutTests(TestCase):
|
||||
self.assertEqual(resp2.status_code, 302)
|
||||
self.assertIn('/absences/log/', resp2.url)
|
||||
self.assertIn('date=2026-05-14', resp2.url)
|
||||
|
||||
|
||||
# === ROUND D — Absences tab on /workers/<id>/ + dashboard alert card ===
|
||||
# These tests cover the worker-detail Absences tab (YTD chips + recent-50
|
||||
# table) and the conditional "X absent in last 7 days" stat card on the
|
||||
# admin dashboard. Both pull from the same Absence queryset; the dashboard
|
||||
# card only renders when count > 0.
|
||||
|
||||
class AbsenceWorkerDetailTests(TestCase):
|
||||
"""Round D — Absences tab on /workers/<id>/ shows YTD totals + table."""
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.admin = User.objects.create_user(username='admin', password='pw', is_staff=True)
|
||||
cls.worker = Worker.objects.create(name='W', id_number='1', monthly_salary=Decimal('6000'))
|
||||
cls.project = Project.objects.create(name='P')
|
||||
from django.utils import timezone as _tz
|
||||
current_year = _tz.now().year
|
||||
cls.current_year = current_year
|
||||
Absence.objects.create(worker=cls.worker, date=_date(current_year, 1, 5), reason='sick')
|
||||
Absence.objects.create(worker=cls.worker, date=_date(current_year, 2, 10), reason='sick')
|
||||
Absence.objects.create(worker=cls.worker, date=_date(current_year, 3, 1), reason='annual')
|
||||
# Last year — should NOT count toward YTD
|
||||
Absence.objects.create(worker=cls.worker, date=_date(current_year - 1, 5, 1), reason='sick')
|
||||
|
||||
def test_worker_detail_includes_ytd_totals(self):
|
||||
self.client.force_login(self.admin)
|
||||
resp = self.client.get(f'/workers/{self.worker.id}/')
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
ytd = resp.context['absence_ytd_totals']
|
||||
# Two sick + one annual this year; last year's sick is excluded.
|
||||
self.assertEqual(ytd.get('sick'), 2)
|
||||
self.assertEqual(ytd.get('annual'), 1)
|
||||
# Last year's sick absence is in worker_absences (not filtered) but
|
||||
# NOT in YTD totals. All 4 absences appear in the recent-50 table.
|
||||
worker_absences = resp.context['worker_absences']
|
||||
all_dates = [a.date for a in worker_absences]
|
||||
self.assertEqual(len(all_dates), 4)
|
||||
|
||||
def test_worker_detail_renders_tab_and_chips(self):
|
||||
self.client.force_login(self.admin)
|
||||
resp = self.client.get(f'/workers/{self.worker.id}/')
|
||||
self.assertContains(resp, 'Absences') # tab label
|
||||
self.assertContains(resp, 'Sick: 2') # YTD chip
|
||||
self.assertContains(resp, 'Annual Leave: 1')
|
||||
|
||||
|
||||
class AbsenceDashboardCardTests(TestCase):
|
||||
"""Round D — Admin dashboard shows 'X absent in last 7 days' alert card
|
||||
when count > 0; renders nothing when count is 0."""
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.admin = User.objects.create_user(username='admin', password='pw', is_staff=True)
|
||||
cls.worker = Worker.objects.create(name='W', id_number='1', monthly_salary=Decimal('6000'))
|
||||
|
||||
def setUp(self):
|
||||
self.client.force_login(self.admin)
|
||||
|
||||
def test_card_shows_when_recent_absences_exist(self):
|
||||
from datetime import timedelta as _td
|
||||
from django.utils import timezone as _tz
|
||||
today = _tz.now().date()
|
||||
Absence.objects.create(worker=self.worker, date=today, reason='sick')
|
||||
Absence.objects.create(worker=self.worker, date=today - _td(days=3), reason='annual')
|
||||
# Outside the 7-day window — should NOT count
|
||||
Absence.objects.create(worker=self.worker, date=today - _td(days=10), reason='other')
|
||||
resp = self.client.get('/')
|
||||
self.assertEqual(resp.context['absences_recent_count'], 2)
|
||||
self.assertContains(resp, 'absent in last 7 days')
|
||||
|
||||
def test_card_hidden_when_zero(self):
|
||||
Absence.objects.all().delete()
|
||||
resp = self.client.get('/')
|
||||
self.assertEqual(resp.context['absences_recent_count'], 0)
|
||||
self.assertNotContains(resp, 'absent in last 7 days')
|
||||
|
||||
@ -418,6 +418,15 @@ def index(request):
|
||||
).count()
|
||||
certs_alert_total = certs_expired_count + certs_expiring_count
|
||||
|
||||
# === ABSENCES IN LAST 7 DAYS (Round D dashboard alert) ===
|
||||
# Conditional stat card — only renders when count > 0. Same look as
|
||||
# the existing cert-expiry alert card. Admin-only (this branch only
|
||||
# runs for is_admin()).
|
||||
seven_days_ago = today - datetime.timedelta(days=7)
|
||||
absences_recent_count = Absence.objects.filter(
|
||||
date__gte=seven_days_ago,
|
||||
).count()
|
||||
|
||||
context = {
|
||||
'is_admin': True,
|
||||
'outstanding_payments': outstanding_payments,
|
||||
@ -437,6 +446,9 @@ def index(request):
|
||||
'certs_expired_count': certs_expired_count,
|
||||
'certs_expiring_count': certs_expiring_count,
|
||||
'certs_alert_total': certs_alert_total,
|
||||
# Absences-in-last-7-days alert card (rendered only when > 0)
|
||||
'absences_recent_count': absences_recent_count,
|
||||
'seven_days_ago': seven_days_ago,
|
||||
# Empty on the home dashboard — modal opens clean (no pre-selected filters)
|
||||
'selected_project_ids': [],
|
||||
'selected_team_ids': [],
|
||||
@ -1533,6 +1545,30 @@ def worker_detail(request, worker_id):
|
||||
# --- Active loans / advances ---
|
||||
active_loans = worker.loans.filter(active=True).order_by('-date')
|
||||
|
||||
# === ABSENCES TAB (Round D) ===
|
||||
# Year-to-date count of absences per reason, for the small chip-row
|
||||
# panel at the top of the Absences tab on the worker detail page.
|
||||
current_year = timezone.now().year
|
||||
ytd_rows = (
|
||||
worker.absences
|
||||
.filter(date__year=current_year)
|
||||
.values('reason')
|
||||
.annotate(total=Count('id'))
|
||||
)
|
||||
absence_ytd_totals = {row['reason']: row['total'] for row in ytd_rows}
|
||||
|
||||
# Recent absences for the table below the chip row. select_related is
|
||||
# important so the template doesn't trigger N+1 on project / logged_by.
|
||||
worker_absences = (
|
||||
worker.absences
|
||||
.select_related('project', 'logged_by', 'payroll_adjustment')
|
||||
.all()[:50]
|
||||
)
|
||||
# Total absence count for the tab badge — worker_absences is sliced for
|
||||
# display so |length would cap at 50. Match peer tabs (certs/warnings)
|
||||
# that use a full .count() for their badges.
|
||||
absence_total_count = worker.absences.count()
|
||||
|
||||
context = {
|
||||
'worker': worker,
|
||||
'projects_worked': projects_worked,
|
||||
@ -1544,6 +1580,12 @@ def worker_detail(request, worker_id):
|
||||
'certs': certs,
|
||||
'warnings': warnings,
|
||||
'active_loans': active_loans,
|
||||
# Absences tab
|
||||
'absence_ytd_totals': absence_ytd_totals,
|
||||
'worker_absences': worker_absences,
|
||||
'absence_total_count': absence_total_count,
|
||||
'absence_reason_choices': Absence.REASON_CHOICES,
|
||||
'today': timezone.now().date(),
|
||||
}
|
||||
return render(request, 'core/workers/detail.html', context)
|
||||
|
||||
|
||||
@ -2185,3 +2185,18 @@ th.sortable.sorted .sort-arrow { opacity: 1; }
|
||||
opacity: 0.75;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* === Absence reason badges — shared between /absences/ list and worker detail tab === */
|
||||
/* Single source of truth — previously duplicated in absences/list.html. */
|
||||
/* Reuses the existing semantic badge palette so dark/light theme switching */
|
||||
/* works out of the box. Green-ish for "valid" leave (sick/family/annual), */
|
||||
/* neutral for unpaid/other, amber for IOD, purple-ish for the disciplinary */
|
||||
/* reasons (suspension, absconded). */
|
||||
.badge-absence-sick { background: var(--badge-bonus-bg); color: var(--badge-bonus-fg); }
|
||||
.badge-absence-family { background: var(--badge-bonus-bg); color: var(--badge-bonus-fg); }
|
||||
.badge-absence-annual { background: var(--badge-bonus-bg); color: var(--badge-bonus-fg); }
|
||||
.badge-absence-unpaid { background: var(--badge-neutral-bg, #6c757d); color: #fff; }
|
||||
.badge-absence-iod { background: var(--badge-overtime-bg, #ffc107); color: var(--badge-overtime-fg, #000); }
|
||||
.badge-absence-suspension { background: var(--badge-deduction-bg); color: var(--badge-deduction-fg); }
|
||||
.badge-absence-absconded { background: var(--badge-deduction-bg); color: var(--badge-deduction-fg); }
|
||||
.badge-absence-other { background: var(--badge-neutral-bg, #6c757d); color: #fff; }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user