--- type: API Endpoint title: Admin routes description: Employee CRUD + pay rates, org-wide and per-employee stats, shift planning admin, payroll, and CSV export — all requireAdmin. resource: backend/src/routes/admin.ts tags: [api, admin] timestamp: 2026-08-16T00:00:00Z --- # Admin routes Mounted at `/api/admin`. Every route requires `requireAuth` + `requireAdmin` (role resolved from `ADMIN_EMAILS`, see [Auth flow](/docs/architecture/auth-flow.md)). # Examples ``` GET /api/admin/employees 200 -> { "employees": (Employee & { hourly_rate: number })[] } // includes inactive (soft-deleted) rows ``` `hourly_rate` is the employee's *current* rate (`getCurrentRate`, see [pay_rates](/docs/data-model/pay-rates.md)) — joined in at read time, not a column on `employees` itself. ``` POST /api/admin/employees/:id/rate Body: { "hourly_rate": number, "valid_from"?: "YYYY-MM-DD" } 201 -> { "hourly_rate": number } ``` Appends a new [pay_rates](/docs/data-model/pay-rates.md) row rather than overwriting — see that doc for the retroactive-first-rate behavior. ``` POST /api/admin/employees Body: { "email": string, "name"?: string } 201 -> { "employee": Employee } ``` Upserts by email — see [employees](/docs/data-model/employees.md) for the reactivate-on-re-add behavior. ``` DELETE /api/admin/employees/:id 204 on success, 404 if not found. ``` Soft delete only (`active = 0`) — never removes the row or its attendance history. ``` GET /api/admin/stats?month=YYYY-MM 200 -> { "range": { "from": string, "to": string }, "totals": { "totalWorkedMs": number, "totalBreakMs": number, "shiftCount": number }, "employees": [{ "employee": Employee, "totalWorkedMs": number, "totalBreakMs": number, "shiftCount": number }] } ``` Same calendar-month default as the employee's own stats endpoint. ``` GET /api/admin/stats/:id?month=YYYY-MM 200 -> { "employee": Employee, "range": {...}, "sessions": Session[], "totalWorkedMs": number, "totalBreakMs": number, "shiftCount": number } 404 -> employee not found ``` ``` GET /api/admin/stats/:id/export?month=YYYY-MM 200, Content-Type: text/csv; charset=utf-8 Content-Disposition: attachment; filename="_.csv" ``` Columns: `Datum, Příchod, Odchod, Pauza (h:mm), Odpracováno (h:mm)`, one row per shift, sorted chronologically (`backend/src/util/csv.ts`). Prefixed with a UTF-8 BOM so Excel renders the Czech diacritics correctly. Downloaded from the frontend as a plain `` — the session cookie rides along automatically since it's a same-origin GET. ## Shift planning admin See [shift_slots](/docs/data-model/shift-slots.md) / [shift_signups](/docs/data-model/shift-signups.md) for the data model. ``` GET /api/admin/shifts?period=YYYY-MM 200 -> { "slots": AdminSlot[] } // AdminSlot includes occupied employees per slot ``` Same lazy-generation trigger as the employee-side [`GET /api/shifts/available`](./shifts-routes.md). ``` POST /api/admin/shifts Body: { "date", "start_time", "end_time", "capacity", "note"? } 201 -> { "id": number } 400 -> "Stejná směna už existuje." (unique date+start+end already taken) ``` ``` PATCH /api/admin/shifts/:slotId Body: { "date", "start_time", "end_time", "capacity", "note"? } 200 -> { "ok": true } 400 -> capacity dropped below the number of already-approved signups ``` ``` POST /api/admin/shifts/:slotId/assign Body: { "employeeId": number } 201 -> { "ok": true } ``` Goes through the exact same collision/capacity checks as a self-signup — see [shift_signups](/docs/data-model/shift-signups.md). ``` DELETE /api/admin/shifts/:slotId/assign/:employeeId 204 on success ``` ## Payroll admin See [month_closures](/docs/data-model/month-closures.md) and [payroll](/docs/data-model/payroll.md) for the state machines these drive. ``` GET /api/admin/payroll?period=YYYY-MM 200 -> { "rows": PayrollOverviewRow[] } ``` One row per **active** employee, whether or not they've touched their closure for that period yet (`closure_status` defaults to `"waiting_employee"` when no row exists). ``` POST /api/admin/payroll/:employeeId/adjustments Body: { "period", "tips_amount", "bonus_amount", "other_amount" } 200 -> { "payroll": Payroll } 400 -> not confirmed yet, or already locked ``` ``` POST /api/admin/payroll/:employeeId/lock Body: { "period": "YYYY-MM" } 200 -> { "payroll": Payroll } 400 -> employee hasn't confirmed, or still has an open shift ``` ``` POST /api/admin/payroll/:employeeId/reopen Body: { "period": "YYYY-MM" } 200 -> { "payroll": Payroll } 400 -> not currently locked+ready (e.g. already paid) ``` ``` POST /api/admin/payroll/:employeeId/paid Body: { "period": "YYYY-MM" } 200 -> { "payroll": Payroll } 400 -> not yet locked (status isn't "ready") ``` ``` GET /api/admin/payroll/export?period=YYYY-MM 200, Content-Type: text/csv; charset=utf-8 Content-Disposition: attachment; filename="mzdy_.csv" ``` Built by `payrollToCsv` in `backend/src/util/csv.ts`, same BOM-prefixed pattern as the attendance CSV export below. # Related - [employees](/docs/data-model/employees.md) - [attendance_events](/docs/data-model/attendance-events.md) - [pay_rates](/docs/data-model/pay-rates.md), [month_closures](/docs/data-model/month-closures.md), [payroll](/docs/data-model/payroll.md) - [shift_slots](/docs/data-model/shift-slots.md), [shift_signups](/docs/data-model/shift-signups.md) - [Attendance routes](./attendance-routes.md) - [Shift planning routes (employee)](./shifts-routes.md), [Closure routes (employee)](./closure-routes.md) - [Frontend stores](/docs/frontend/stores.md) — `adminStore` owns the selected month and drives both the summary table and the per-employee chart off it; `adminShiftStore` and `adminPayrollStore` own the other two admin tabs