Initial commit: EatMe attendance tracker
Google-SSO PWA for bistro employee clock-in/out, admin employee management, and stats with CSV export. Express + SQLite backend, React + Zustand frontend in the light-mono-tui design language. Multi-stage Dockerfile, compose.yaml for image-based deploys, nginx reverse-proxy template, and an OKF documentation bundle in docs/. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
11
.dockerignore
Normal file
@@ -0,0 +1,11 @@
|
||||
**/node_modules
|
||||
backend/dist
|
||||
backend/data
|
||||
backend/.env
|
||||
frontend/dist
|
||||
frontend/dev-dist
|
||||
frontend/.env
|
||||
.git
|
||||
docs
|
||||
branding
|
||||
*.md
|
||||
11
.env.example
Normal file
@@ -0,0 +1,11 @@
|
||||
# Used by `docker compose` for both the frontend build arg and the
|
||||
# container's runtime env — copy to .env next to compose.yaml on the host
|
||||
# and fill in real values. Not read by the apps directly (see
|
||||
# backend/.env.example and frontend/.env.example for local dev).
|
||||
|
||||
VITE_GOOGLE_CLIENT_ID=766105276369-qa2iojmdc89ec3bqbmmg0ujmp9114scr.apps.googleusercontent.com
|
||||
GOOGLE_CLIENT_ID=766105276369-qa2iojmdc89ec3bqbmmg0ujmp9114scr.apps.googleusercontent.com
|
||||
ADMIN_EMAILS=mipemco@gmail.com,filip.thurrigl@gmail.com
|
||||
JWT_SECRET=change-me-generate-with-openssl-rand-hex-32
|
||||
CORS_ORIGIN=https://eatme.mipem.co
|
||||
COOKIE_SECURE=true
|
||||
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.env
|
||||
42
Dockerfile
Normal file
@@ -0,0 +1,42 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# ---- frontend build: React/Vite PWA -> static dist/ ----
|
||||
FROM node:22-slim AS frontend-build
|
||||
WORKDIR /app/frontend
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY frontend/ .
|
||||
# Vite inlines VITE_* env vars at build time, so the Google client id has to
|
||||
# be supplied as a build arg: docker build --build-arg VITE_GOOGLE_CLIENT_ID=...
|
||||
ARG VITE_GOOGLE_CLIENT_ID
|
||||
ENV VITE_GOOGLE_CLIENT_ID=$VITE_GOOGLE_CLIENT_ID
|
||||
RUN npm run build
|
||||
|
||||
# ---- backend build: TypeScript -> single esbuild bundle ----
|
||||
FROM node:22-slim AS backend-build
|
||||
WORKDIR /app/backend
|
||||
COPY backend/package.json backend/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY backend/ .
|
||||
RUN npm run build
|
||||
|
||||
# ---- runtime: only prod deps + the two build outputs ----
|
||||
FROM node:22-slim AS runtime
|
||||
ENV NODE_ENV=production
|
||||
WORKDIR /app
|
||||
|
||||
# better-sqlite3 has a native binding, so it's installed for real here rather
|
||||
# than copied from another stage — same base image as backend-build, so the
|
||||
# compiled binary always matches what it runs on.
|
||||
COPY backend/package.json backend/package-lock.json ./
|
||||
RUN npm ci --omit=dev && npm cache clean --force
|
||||
|
||||
COPY --from=backend-build /app/backend/dist ./dist
|
||||
COPY --from=frontend-build /app/frontend/dist ./dist/public
|
||||
|
||||
ENV PORT=4000
|
||||
ENV DB_PATH=/app/data/eatme.db
|
||||
EXPOSE 4000
|
||||
VOLUME ["/app/data"]
|
||||
|
||||
CMD ["node", "dist/index.js"]
|
||||
96
README.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# EatMe — Docházka
|
||||
|
||||
Evidence docházky pro bistro EatMe. Zaměstnanci se přihlašují Google účtem a
|
||||
logují příchod / odchod / start a konec pauzy; admin (majitel) spravuje seznam
|
||||
zaměstnanců (jen e-mail, žádná hesla) a vidí souhrnné i individuální statistiky
|
||||
s grafem a exportem do CSV.
|
||||
|
||||
```
|
||||
backend/ Express + TypeScript API, SQLite (better-sqlite3)
|
||||
frontend/ React + TypeScript PWA (Vite), Google Sign-In, Zustand
|
||||
docs/ OKF (Open Knowledge Format) — architektura, datový model, provoz
|
||||
branding/ Logo a vygenerované favicony/PWA ikony z podkladu bistra
|
||||
deploy/ Šablony configů pro hosty mimo tento repo (nginx server block)
|
||||
Dockerfile Multi-stage build — jeden image pro API i frontend
|
||||
compose.yaml Produkční nasazení hotového image (viz Produkce níž)
|
||||
```
|
||||
|
||||
Vzhled je podle `~/doc/concepts/ui/light-mono-tui.md` — světlý monochromní
|
||||
"TUI" styl (mono font, ostré hrany, žádné barvy, inverze pro aktivní stav).
|
||||
|
||||
## Požadavky
|
||||
|
||||
- Node.js 22+
|
||||
- Google Cloud projekt s nakonfigurovaným OAuth Client ID (Web application)
|
||||
— origins musí obsahovat `http://localhost:5174` pro vývoj a produkční
|
||||
doménu. Client ID se nastavuje do `GOOGLE_CLIENT_ID` (backend) a
|
||||
`VITE_GOOGLE_CLIENT_ID` (frontend) — musí být **stejné**.
|
||||
|
||||
## Vývoj
|
||||
|
||||
```bash
|
||||
# backend
|
||||
cd backend
|
||||
cp .env.example .env # doplň GOOGLE_CLIENT_ID, ADMIN_EMAILS, JWT_SECRET
|
||||
npm install
|
||||
npm run dev # http://localhost:4000
|
||||
|
||||
# frontend (v druhém terminálu)
|
||||
cd frontend
|
||||
cp .env.example .env # VITE_GOOGLE_CLIENT_ID stejné jako backend
|
||||
npm install
|
||||
npm run dev # http://localhost:5174 (port je pinnutý — musí sedět s Google origin)
|
||||
```
|
||||
|
||||
Frontend v dev módu proxuje `/api/*` na backend (viz `frontend/vite.config.ts`).
|
||||
|
||||
### Typecheck / build
|
||||
|
||||
```bash
|
||||
cd backend && npm run typecheck && npm run build # -> dist/index.js (esbuild bundle)
|
||||
cd frontend && npm run build # -> dist/ (Vite, PWA)
|
||||
```
|
||||
|
||||
## Produkce (Docker)
|
||||
|
||||
Jeden multi-stage `Dockerfile` v rootu postaví frontend i backend a spustí je
|
||||
jako jeden kontejner — backend servíruje API na `/api/*` a staticky sbalený
|
||||
frontend na všem ostatním.
|
||||
|
||||
Image se buildí lokálně (pro platformu cílového serveru) a na server se
|
||||
posílá hotový, ne zdrojáky:
|
||||
|
||||
```bash
|
||||
# buildni pro platformu serveru (na Apple Silicon Macu proti x86_64 serveru
|
||||
# je --platform linux/amd64 povinné, jinak image tam vůbec nenaběhne)
|
||||
docker build --platform linux/amd64 \
|
||||
--build-arg VITE_GOOGLE_CLIENT_ID=<client-id> \
|
||||
-t eatme:latest .
|
||||
|
||||
# přenes hotový image přes ssh (žádný build na serveru)
|
||||
docker save eatme:latest | gzip | ssh <host> "gunzip | docker load"
|
||||
|
||||
# pošli jen compose.yaml + .env (viz .env.example) a nastartuj
|
||||
scp compose.yaml <host>:~/eatme/compose.yaml
|
||||
ssh <host> "cd ~/eatme && docker compose up -d"
|
||||
```
|
||||
|
||||
`VITE_GOOGLE_CLIENT_ID` musí jít jako **build arg** (Vite ho zapéká při
|
||||
buildu, ne za běhu — v `.env` na serveru se proto řeší jen runtime proměnné).
|
||||
Pojmenovaný volume `eatme_data` (viz `compose.yaml`) drží SQLite databázi
|
||||
mimo kontejner, ať přežije redeploy.
|
||||
|
||||
Před kontejner patří TLS-terminující reverse proxy (mimo tento repo) —
|
||||
šablona server blocku pro nginx je v `deploy/eatme.mipem.co`, proxuje na
|
||||
`http://<deploy-host>:8092`.
|
||||
|
||||
## Datový model / provoz
|
||||
|
||||
Zaměstnanci jsou whitelist e-mailů spravovaný adminem (`ADMIN_EMAILS` v env —
|
||||
samostatná role, není v tabulce zaměstnanců). Odebrání zaměstnance je pouze
|
||||
soft-delete (`active = 0`) — historie docházky zůstává, opětovným přidáním
|
||||
stejného e-mailu se účet reaktivuje. Statistiky se počítají po kalendářních
|
||||
měsících (`?month=YYYY-MM`), s možností listovat měsíci v adminově detailu
|
||||
zaměstnance (graf + export CSV).
|
||||
|
||||
Víc v `docs/` (OKF bundle).
|
||||
22
backend/.env.example
Normal file
@@ -0,0 +1,22 @@
|
||||
# Port the API listens on
|
||||
PORT=4000
|
||||
|
||||
# OAuth client ID from Google Cloud Console (Web application).
|
||||
# Must match the client ID used by the frontend's Google Sign-In button.
|
||||
GOOGLE_CLIENT_ID=your-google-client-id.apps.googleusercontent.com
|
||||
|
||||
# Comma-separated list of email addresses that get admin access.
|
||||
# Admins do not need a row in the employees table.
|
||||
ADMIN_EMAILS=kamarad@example.com
|
||||
|
||||
# Secret used to sign session JWTs. Generate with: openssl rand -hex 32
|
||||
JWT_SECRET=change-me-to-a-long-random-string
|
||||
|
||||
# Where the SQLite database file lives.
|
||||
DB_PATH=./data/eatme.db
|
||||
|
||||
# Origin of the frontend app, for CORS + cookie settings.
|
||||
CORS_ORIGIN=http://localhost:5173
|
||||
|
||||
# Set to "true" behind HTTPS in production so the session cookie gets Secure.
|
||||
COOKIE_SECURE=false
|
||||
9
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
node_modules/
|
||||
dist/
|
||||
data/
|
||||
.env
|
||||
*.tsbuildinfo
|
||||
*.db
|
||||
*.db-journal
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
20
backend/esbuild.config.js
Normal file
@@ -0,0 +1,20 @@
|
||||
const esbuild = require("esbuild");
|
||||
|
||||
esbuild
|
||||
.build({
|
||||
entryPoints: ["src/index.ts"],
|
||||
outfile: "dist/index.js",
|
||||
bundle: true,
|
||||
platform: "node",
|
||||
target: "node22",
|
||||
format: "cjs",
|
||||
minify: true,
|
||||
sourcemap: true,
|
||||
// better-sqlite3 ships a native .node binding — it can't be bundled,
|
||||
// so it stays a real dependency installed alongside the bundle.
|
||||
external: ["better-sqlite3"],
|
||||
})
|
||||
.then(() => {
|
||||
console.log("backend bundled -> dist/index.js");
|
||||
})
|
||||
.catch(() => process.exit(1));
|
||||
34
backend/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "eatme-backend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "EatMe bistro — attendance tracking API",
|
||||
"type": "commonjs",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc --noEmit && node esbuild.config.js",
|
||||
"start": "node dist/index.js",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^11.9.1",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^4.21.2",
|
||||
"google-auth-library": "^9.15.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.12",
|
||||
"@types/cookie-parser": "^1.4.8",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jsonwebtoken": "^9.0.7",
|
||||
"@types/node": "^22.10.2",
|
||||
"esbuild": "^0.28.2",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
26
backend/src/auth/google.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { OAuth2Client } from "google-auth-library";
|
||||
import { env } from "../env";
|
||||
|
||||
const client = new OAuth2Client(env.googleClientId);
|
||||
|
||||
export interface GoogleProfile {
|
||||
email: string;
|
||||
name: string | null;
|
||||
emailVerified: boolean;
|
||||
}
|
||||
|
||||
export async function verifyGoogleIdToken(idToken: string): Promise<GoogleProfile> {
|
||||
const ticket = await client.verifyIdToken({
|
||||
idToken,
|
||||
audience: env.googleClientId,
|
||||
});
|
||||
const payload = ticket.getPayload();
|
||||
if (!payload?.email) {
|
||||
throw new Error("Google token payload is missing an email");
|
||||
}
|
||||
return {
|
||||
email: payload.email.toLowerCase(),
|
||||
name: payload.name ?? null,
|
||||
emailVerified: payload.email_verified ?? false,
|
||||
};
|
||||
}
|
||||
28
backend/src/auth/middleware.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import { readSession } from "./session";
|
||||
|
||||
export function requireAuth(req: Request, res: Response, next: NextFunction): void {
|
||||
const user = readSession(req);
|
||||
if (!user) {
|
||||
res.status(401).json({ error: "Not authenticated" });
|
||||
return;
|
||||
}
|
||||
req.user = user;
|
||||
next();
|
||||
}
|
||||
|
||||
export function requireAdmin(req: Request, res: Response, next: NextFunction): void {
|
||||
if (req.user?.role !== "admin") {
|
||||
res.status(403).json({ error: "Admin access required" });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
export function requireEmployee(req: Request, res: Response, next: NextFunction): void {
|
||||
if (req.user?.employeeId == null) {
|
||||
res.status(403).json({ error: "No employee profile linked to this account" });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
}
|
||||
32
backend/src/auth/session.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import jwt from "jsonwebtoken";
|
||||
import type { Request, Response } from "express";
|
||||
import { env } from "../env";
|
||||
import type { SessionUser } from "../types";
|
||||
|
||||
export const SESSION_COOKIE = "eatme_session";
|
||||
const SESSION_TTL_SECONDS = 60 * 60 * 12; // 12h
|
||||
|
||||
export function issueSession(res: Response, user: SessionUser): void {
|
||||
const token = jwt.sign(user, env.jwtSecret, { expiresIn: SESSION_TTL_SECONDS });
|
||||
res.cookie(SESSION_COOKIE, token, {
|
||||
httpOnly: true,
|
||||
secure: env.cookieSecure,
|
||||
sameSite: "lax",
|
||||
maxAge: SESSION_TTL_SECONDS * 1000,
|
||||
path: "/",
|
||||
});
|
||||
}
|
||||
|
||||
export function clearSession(res: Response): void {
|
||||
res.clearCookie(SESSION_COOKIE, { path: "/" });
|
||||
}
|
||||
|
||||
export function readSession(req: Request): SessionUser | null {
|
||||
const token = req.cookies?.[SESSION_COOKIE];
|
||||
if (!token) return null;
|
||||
try {
|
||||
return jwt.verify(token, env.jwtSecret) as SessionUser;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
30
backend/src/db/index.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import Database from "better-sqlite3";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { env } from "../env";
|
||||
|
||||
fs.mkdirSync(path.dirname(env.dbPath), { recursive: true });
|
||||
|
||||
export const db = new Database(env.dbPath);
|
||||
db.pragma("journal_mode = WAL");
|
||||
db.pragma("foreign_keys = ON");
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS employees (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
name TEXT,
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS attendance_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
employee_id INTEGER NOT NULL REFERENCES employees(id),
|
||||
type TEXT NOT NULL CHECK (type IN ('clock_in', 'clock_out', 'break_start', 'break_end')),
|
||||
ts TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_attendance_employee_ts
|
||||
ON attendance_events (employee_id, ts);
|
||||
`);
|
||||
22
backend/src/env.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import "dotenv/config";
|
||||
|
||||
function required(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (!value) {
|
||||
throw new Error(`Missing required environment variable: ${name}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export const env = {
|
||||
port: Number(process.env.PORT ?? 4000),
|
||||
googleClientId: required("GOOGLE_CLIENT_ID"),
|
||||
adminEmails: required("ADMIN_EMAILS")
|
||||
.split(",")
|
||||
.map((e) => e.trim().toLowerCase())
|
||||
.filter(Boolean),
|
||||
jwtSecret: required("JWT_SECRET"),
|
||||
dbPath: process.env.DB_PATH ?? "./data/eatme.db",
|
||||
corsOrigin: process.env.CORS_ORIGIN ?? "http://localhost:5173",
|
||||
cookieSecure: process.env.COOKIE_SECURE === "true",
|
||||
};
|
||||
43
backend/src/index.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import "./db"; // ensure schema is created before handling requests
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import cookieParser from "cookie-parser";
|
||||
import cors from "cors";
|
||||
import express from "express";
|
||||
import { env } from "./env";
|
||||
import { authRouter } from "./routes/auth";
|
||||
import { attendanceRouter } from "./routes/attendance";
|
||||
import { adminRouter } from "./routes/admin";
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use(cors({ origin: env.corsOrigin, credentials: true }));
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
|
||||
app.get("/api/health", (_req, res) => res.json({ ok: true }));
|
||||
|
||||
app.use("/api/auth", authRouter);
|
||||
app.use("/api/attendance", attendanceRouter);
|
||||
app.use("/api/admin", adminRouter);
|
||||
|
||||
// In the production Docker image the built frontend is copied next to this
|
||||
// bundle as dist/public. Serve it (with an SPA fallback) when present; in dev
|
||||
// the frontend runs on its own Vite server, so this is simply skipped.
|
||||
const publicDir = path.join(__dirname, "public");
|
||||
if (fs.existsSync(publicDir)) {
|
||||
app.use(express.static(publicDir));
|
||||
app.get(/^(?!\/api\/).*/, (_req, res) => {
|
||||
res.sendFile(path.join(publicDir, "index.html"));
|
||||
});
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
});
|
||||
|
||||
app.listen(env.port, () => {
|
||||
console.log(`EatMe API listening on http://localhost:${env.port}`);
|
||||
});
|
||||
109
backend/src/routes/admin.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { Router } from "express";
|
||||
import { z } from "zod";
|
||||
import { requireAdmin, requireAuth } from "../auth/middleware";
|
||||
import {
|
||||
addEmployee,
|
||||
deactivateEmployee,
|
||||
findEmployeeById,
|
||||
listEmployees,
|
||||
} from "../services/employees";
|
||||
import { eventsInRange, summarize } from "../services/attendance";
|
||||
import { parseRange } from "../util/dateRange";
|
||||
import { sessionsToCsv } from "../util/csv";
|
||||
|
||||
export const adminRouter = Router();
|
||||
adminRouter.use(requireAuth, requireAdmin);
|
||||
|
||||
adminRouter.get("/employees", (_req, res) => {
|
||||
res.json({ employees: listEmployees() });
|
||||
});
|
||||
|
||||
const addEmployeeSchema = z.object({
|
||||
email: z.string().email(),
|
||||
name: z.string().trim().min(1).optional(),
|
||||
});
|
||||
|
||||
adminRouter.post("/employees", (req, res) => {
|
||||
const parsed = addEmployeeSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: "Valid email is required" });
|
||||
return;
|
||||
}
|
||||
const employee = addEmployee(parsed.data.email, parsed.data.name ?? null);
|
||||
res.status(201).json({ employee });
|
||||
});
|
||||
|
||||
adminRouter.delete("/employees/:id", (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id)) {
|
||||
res.status(400).json({ error: "Invalid employee id" });
|
||||
return;
|
||||
}
|
||||
const removed = deactivateEmployee(id);
|
||||
if (!removed) {
|
||||
res.status(404).json({ error: "Employee not found" });
|
||||
return;
|
||||
}
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
adminRouter.get("/stats", (req, res) => {
|
||||
const { fromIso, toIso } = parseRange(req.query as Record<string, unknown>);
|
||||
const now = new Date();
|
||||
const employees = listEmployees();
|
||||
|
||||
const perEmployee = employees.map((employee) => {
|
||||
const events = eventsInRange(employee.id, fromIso, toIso);
|
||||
const stats = summarize(events, now);
|
||||
return {
|
||||
employee,
|
||||
totalWorkedMs: stats.totalWorkedMs,
|
||||
totalBreakMs: stats.totalBreakMs,
|
||||
shiftCount: stats.shiftCount,
|
||||
};
|
||||
});
|
||||
|
||||
const totals = perEmployee.reduce(
|
||||
(acc, e) => ({
|
||||
totalWorkedMs: acc.totalWorkedMs + e.totalWorkedMs,
|
||||
totalBreakMs: acc.totalBreakMs + e.totalBreakMs,
|
||||
shiftCount: acc.shiftCount + e.shiftCount,
|
||||
}),
|
||||
{ totalWorkedMs: 0, totalBreakMs: 0, shiftCount: 0 }
|
||||
);
|
||||
|
||||
res.json({ range: { from: fromIso, to: toIso }, totals, employees: perEmployee });
|
||||
});
|
||||
|
||||
adminRouter.get("/stats/:id", (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const employee = Number.isInteger(id) ? findEmployeeById(id) : undefined;
|
||||
if (!employee) {
|
||||
res.status(404).json({ error: "Employee not found" });
|
||||
return;
|
||||
}
|
||||
const { fromIso, toIso } = parseRange(req.query as Record<string, unknown>);
|
||||
const events = eventsInRange(employee.id, fromIso, toIso);
|
||||
const stats = summarize(events, new Date());
|
||||
res.json({ employee, range: { from: fromIso, to: toIso }, ...stats });
|
||||
});
|
||||
|
||||
adminRouter.get("/stats/:id/export", (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const employee = Number.isInteger(id) ? findEmployeeById(id) : undefined;
|
||||
if (!employee) {
|
||||
res.status(404).json({ error: "Employee not found" });
|
||||
return;
|
||||
}
|
||||
const { fromIso, toIso } = parseRange(req.query as Record<string, unknown>);
|
||||
const events = eventsInRange(employee.id, fromIso, toIso);
|
||||
const { sessions } = summarize(events, new Date());
|
||||
const csv = sessionsToCsv(sessions);
|
||||
|
||||
const period = fromIso.slice(0, 7);
|
||||
const safeName = employee.email.replace(/[^a-z0-9.@-]/gi, "_");
|
||||
|
||||
res.setHeader("Content-Type", "text/csv; charset=utf-8");
|
||||
res.setHeader("Content-Disposition", `attachment; filename="${safeName}_${period}.csv"`);
|
||||
res.send("" + csv); // BOM so Excel opens the Czech diacritics as UTF-8
|
||||
});
|
||||
51
backend/src/routes/attendance.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Router } from "express";
|
||||
import { z } from "zod";
|
||||
import { requireAuth, requireEmployee } from "../auth/middleware";
|
||||
import {
|
||||
InvalidTransitionError,
|
||||
eventsInRange,
|
||||
getLiveStatus,
|
||||
recordEvent,
|
||||
summarize,
|
||||
} from "../services/attendance";
|
||||
import { parseRange } from "../util/dateRange";
|
||||
|
||||
export const attendanceRouter = Router();
|
||||
attendanceRouter.use(requireAuth, requireEmployee);
|
||||
|
||||
const eventSchema = z.object({
|
||||
type: z.enum(["clock_in", "clock_out", "break_start", "break_end"]),
|
||||
});
|
||||
|
||||
attendanceRouter.post("/event", (req, res) => {
|
||||
const parsed = eventSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: "Invalid event type" });
|
||||
return;
|
||||
}
|
||||
|
||||
const employeeId = req.user!.employeeId!;
|
||||
try {
|
||||
const event = recordEvent(employeeId, parsed.data.type);
|
||||
res.status(201).json({ event, status: getLiveStatus(employeeId) });
|
||||
} catch (err) {
|
||||
if (err instanceof InvalidTransitionError) {
|
||||
res.status(409).json({ error: err.message, status: err.current });
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
attendanceRouter.get("/state", (req, res) => {
|
||||
const employeeId = req.user!.employeeId!;
|
||||
res.json({ status: getLiveStatus(employeeId) });
|
||||
});
|
||||
|
||||
attendanceRouter.get("/me", (req, res) => {
|
||||
const employeeId = req.user!.employeeId!;
|
||||
const { fromIso, toIso } = parseRange(req.query as Record<string, unknown>);
|
||||
const events = eventsInRange(employeeId, fromIso, toIso);
|
||||
const stats = summarize(events, new Date());
|
||||
res.json({ range: { from: fromIso, to: toIso }, ...stats });
|
||||
});
|
||||
64
backend/src/routes/auth.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Router } from "express";
|
||||
import { z } from "zod";
|
||||
import { verifyGoogleIdToken } from "../auth/google";
|
||||
import { clearSession, issueSession } from "../auth/session";
|
||||
import { requireAuth } from "../auth/middleware";
|
||||
import { env } from "../env";
|
||||
import { findActiveEmployeeByEmail, touchEmployeeName } from "../services/employees";
|
||||
import type { SessionUser } from "../types";
|
||||
|
||||
export const authRouter = Router();
|
||||
|
||||
const loginSchema = z.object({ credential: z.string().min(1) });
|
||||
|
||||
authRouter.post("/google", async (req, res) => {
|
||||
const parsed = loginSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: "Missing Google credential" });
|
||||
return;
|
||||
}
|
||||
|
||||
let profile;
|
||||
try {
|
||||
profile = await verifyGoogleIdToken(parsed.data.credential);
|
||||
} catch {
|
||||
res.status(401).json({ error: "Invalid Google token" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!profile.emailVerified) {
|
||||
res.status(403).json({ error: "Google account email is not verified" });
|
||||
return;
|
||||
}
|
||||
|
||||
const isAdmin = env.adminEmails.includes(profile.email);
|
||||
const employee = findActiveEmployeeByEmail(profile.email);
|
||||
|
||||
if (!isAdmin && !employee) {
|
||||
res.status(403).json({ error: "This account is not registered as an EatMe employee" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (employee) {
|
||||
touchEmployeeName(employee.id, profile.name);
|
||||
}
|
||||
|
||||
const user: SessionUser = {
|
||||
email: profile.email,
|
||||
name: profile.name,
|
||||
role: isAdmin ? "admin" : "employee",
|
||||
employeeId: employee?.id ?? null,
|
||||
};
|
||||
|
||||
issueSession(res, user);
|
||||
res.json({ user });
|
||||
});
|
||||
|
||||
authRouter.post("/logout", (_req, res) => {
|
||||
clearSession(res);
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
authRouter.get("/me", requireAuth, (req, res) => {
|
||||
res.json({ user: req.user });
|
||||
});
|
||||
134
backend/src/services/attendance.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { db } from "../db";
|
||||
import type { AttendanceEvent, EventType } from "../types";
|
||||
|
||||
export type LiveStatus = "clocked_out" | "working" | "on_break";
|
||||
|
||||
const NEXT_ALLOWED: Record<LiveStatus, EventType[]> = {
|
||||
clocked_out: ["clock_in"],
|
||||
working: ["break_start", "clock_out"],
|
||||
on_break: ["break_end"],
|
||||
};
|
||||
|
||||
function statusAfter(lastType: EventType | null): LiveStatus {
|
||||
switch (lastType) {
|
||||
case null:
|
||||
case "clock_out":
|
||||
return "clocked_out";
|
||||
case "clock_in":
|
||||
case "break_end":
|
||||
return "working";
|
||||
case "break_start":
|
||||
return "on_break";
|
||||
}
|
||||
}
|
||||
|
||||
export function getLastEvent(employeeId: number): AttendanceEvent | undefined {
|
||||
return db
|
||||
.prepare<[number], AttendanceEvent>(
|
||||
"SELECT * FROM attendance_events WHERE employee_id = ? ORDER BY ts DESC, id DESC LIMIT 1"
|
||||
)
|
||||
.get(employeeId);
|
||||
}
|
||||
|
||||
export function getLiveStatus(employeeId: number): LiveStatus {
|
||||
const last = getLastEvent(employeeId);
|
||||
return statusAfter(last?.type ?? null);
|
||||
}
|
||||
|
||||
export class InvalidTransitionError extends Error {
|
||||
constructor(public readonly current: LiveStatus, public readonly attempted: EventType) {
|
||||
super(`Cannot record "${attempted}" while status is "${current}"`);
|
||||
}
|
||||
}
|
||||
|
||||
export function recordEvent(employeeId: number, type: EventType): AttendanceEvent {
|
||||
const current = getLiveStatus(employeeId);
|
||||
if (!NEXT_ALLOWED[current].includes(type)) {
|
||||
throw new InvalidTransitionError(current, type);
|
||||
}
|
||||
const result = db
|
||||
.prepare("INSERT INTO attendance_events (employee_id, type) VALUES (?, ?)")
|
||||
.run(employeeId, type);
|
||||
return db
|
||||
.prepare<[number], AttendanceEvent>("SELECT * FROM attendance_events WHERE id = ?")
|
||||
.get(Number(result.lastInsertRowid))!;
|
||||
}
|
||||
|
||||
export function eventsInRange(employeeId: number, fromIso: string, toIso: string): AttendanceEvent[] {
|
||||
return db
|
||||
.prepare<[number, string, string], AttendanceEvent>(
|
||||
`SELECT * FROM attendance_events
|
||||
WHERE employee_id = ? AND ts >= ? AND ts < ?
|
||||
ORDER BY ts ASC, id ASC`
|
||||
)
|
||||
.all(employeeId, fromIso, toIso);
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
clockIn: string;
|
||||
clockOut: string | null;
|
||||
breaks: { start: string; end: string | null }[];
|
||||
workedMs: number;
|
||||
breakMs: number;
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks a chronological event list and pairs them into shifts. Any session still
|
||||
* open at the end of the range (no clock_out yet) is included with open: true so
|
||||
* "currently working" shows up in today's view, but callers doing historical
|
||||
* totals should treat open sessions' worked time as provisional (measured up to `now`).
|
||||
*/
|
||||
export function buildSessions(events: AttendanceEvent[], now: Date): Session[] {
|
||||
const sessions: Session[] = [];
|
||||
let current: Session | null = null;
|
||||
|
||||
for (const event of events) {
|
||||
const ts = event.ts;
|
||||
if (event.type === "clock_in") {
|
||||
current = { clockIn: ts, clockOut: null, breaks: [], workedMs: 0, breakMs: 0, open: true };
|
||||
sessions.push(current);
|
||||
} else if (!current) {
|
||||
// Orphaned event (e.g. data edited manually) — ignore rather than crash.
|
||||
continue;
|
||||
} else if (event.type === "break_start") {
|
||||
current.breaks.push({ start: ts, end: null });
|
||||
} else if (event.type === "break_end") {
|
||||
const openBreak = current.breaks.find((b) => b.end === null);
|
||||
if (openBreak) openBreak.end = ts;
|
||||
} else if (event.type === "clock_out") {
|
||||
current.clockOut = ts;
|
||||
current.open = false;
|
||||
current = null;
|
||||
}
|
||||
}
|
||||
|
||||
for (const session of sessions) {
|
||||
const end = session.clockOut ? new Date(session.clockOut) : now;
|
||||
const start = new Date(session.clockIn);
|
||||
session.breakMs = session.breaks.reduce((sum, b) => {
|
||||
const bEnd = b.end ? new Date(b.end) : now;
|
||||
return sum + Math.max(0, bEnd.getTime() - new Date(b.start).getTime());
|
||||
}, 0);
|
||||
session.workedMs = Math.max(0, end.getTime() - start.getTime() - session.breakMs);
|
||||
}
|
||||
|
||||
return sessions;
|
||||
}
|
||||
|
||||
export interface StatsSummary {
|
||||
sessions: Session[];
|
||||
totalWorkedMs: number;
|
||||
totalBreakMs: number;
|
||||
shiftCount: number;
|
||||
}
|
||||
|
||||
export function summarize(events: AttendanceEvent[], now: Date): StatsSummary {
|
||||
const sessions = buildSessions(events, now);
|
||||
return {
|
||||
sessions,
|
||||
totalWorkedMs: sessions.reduce((sum, s) => sum + s.workedMs, 0),
|
||||
totalBreakMs: sessions.reduce((sum, s) => sum + s.breakMs, 0),
|
||||
shiftCount: sessions.length,
|
||||
};
|
||||
}
|
||||
52
backend/src/services/employees.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { db } from "../db";
|
||||
import type { Employee } from "../types";
|
||||
|
||||
export function findActiveEmployeeByEmail(email: string): Employee | undefined {
|
||||
return db
|
||||
.prepare<[string], Employee>(
|
||||
"SELECT * FROM employees WHERE email = ? AND active = 1"
|
||||
)
|
||||
.get(email.toLowerCase());
|
||||
}
|
||||
|
||||
export function findEmployeeById(id: number): Employee | undefined {
|
||||
return db.prepare<[number], Employee>("SELECT * FROM employees WHERE id = ?").get(id);
|
||||
}
|
||||
|
||||
export function listEmployees(): Employee[] {
|
||||
return db
|
||||
.prepare<[], Employee>("SELECT * FROM employees ORDER BY active DESC, email ASC")
|
||||
.all();
|
||||
}
|
||||
|
||||
/** Fills in the display name the first time we see a Google profile for this employee. */
|
||||
export function touchEmployeeName(id: number, name: string | null): void {
|
||||
if (!name) return;
|
||||
db.prepare("UPDATE employees SET name = ? WHERE id = ? AND name IS NULL").run(name, id);
|
||||
}
|
||||
|
||||
export function addEmployee(email: string, name: string | null): Employee {
|
||||
const normalized = email.trim().toLowerCase();
|
||||
const existing = db
|
||||
.prepare<[string], Employee>("SELECT * FROM employees WHERE email = ?")
|
||||
.get(normalized);
|
||||
|
||||
if (existing) {
|
||||
db.prepare("UPDATE employees SET active = 1, name = COALESCE(?, name) WHERE id = ?").run(
|
||||
name,
|
||||
existing.id
|
||||
);
|
||||
return findEmployeeById(existing.id)!;
|
||||
}
|
||||
|
||||
const result = db
|
||||
.prepare("INSERT INTO employees (email, name) VALUES (?, ?)")
|
||||
.run(normalized, name);
|
||||
return findEmployeeById(Number(result.lastInsertRowid))!;
|
||||
}
|
||||
|
||||
/** Soft delete: revokes login access but keeps attendance history for stats. */
|
||||
export function deactivateEmployee(id: number): boolean {
|
||||
const result = db.prepare("UPDATE employees SET active = 0 WHERE id = ?").run(id);
|
||||
return result.changes > 0;
|
||||
}
|
||||
33
backend/src/types.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export type EventType = "clock_in" | "clock_out" | "break_start" | "break_end";
|
||||
|
||||
export interface Employee {
|
||||
id: number;
|
||||
email: string;
|
||||
name: string | null;
|
||||
active: 0 | 1;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AttendanceEvent {
|
||||
id: number;
|
||||
employee_id: number;
|
||||
type: EventType;
|
||||
ts: string;
|
||||
}
|
||||
|
||||
export type Role = "admin" | "employee";
|
||||
|
||||
export interface SessionUser {
|
||||
email: string;
|
||||
name: string | null;
|
||||
role: Role;
|
||||
employeeId: number | null;
|
||||
}
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
user?: SessionUser;
|
||||
}
|
||||
}
|
||||
}
|
||||
35
backend/src/util/csv.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { Session } from "../services/attendance";
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString("cs-CZ");
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
return new Date(iso).toLocaleTimeString("cs-CZ", { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
const totalMinutes = Math.round(ms / 60000);
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
return `${hours}:${String(minutes).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function escapeCsvField(value: string): string {
|
||||
return /[",\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value;
|
||||
}
|
||||
|
||||
export function sessionsToCsv(sessions: Session[]): string {
|
||||
const header = ["Datum", "Příchod", "Odchod", "Pauza (h:mm)", "Odpracováno (h:mm)"];
|
||||
const rows = [...sessions]
|
||||
.sort((a, b) => a.clockIn.localeCompare(b.clockIn))
|
||||
.map((s) => [
|
||||
formatDate(s.clockIn),
|
||||
formatTime(s.clockIn),
|
||||
s.clockOut ? formatTime(s.clockOut) : "probíhá",
|
||||
formatDuration(s.breakMs),
|
||||
formatDuration(s.workedMs),
|
||||
]);
|
||||
|
||||
return [header, ...rows].map((row) => row.map(escapeCsvField).join(",")).join("\r\n");
|
||||
}
|
||||
33
backend/src/util/dateRange.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export interface Range {
|
||||
fromIso: string;
|
||||
toIso: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses attendance query params into an [from, to) ISO range. Priority:
|
||||
* 1. explicit ?from=YYYY-MM-DD&to=YYYY-MM-DD (to is inclusive of that whole day)
|
||||
* 2. ?month=YYYY-MM — that whole calendar month
|
||||
* 3. default — the current calendar month
|
||||
*/
|
||||
export function parseRange(query: Record<string, unknown>): Range {
|
||||
const now = new Date();
|
||||
|
||||
const from = typeof query.from === "string" && query.from ? new Date(query.from) : null;
|
||||
const toRaw = typeof query.to === "string" && query.to ? new Date(query.to) : null;
|
||||
|
||||
if (from && toRaw) {
|
||||
const to = new Date(toRaw);
|
||||
to.setDate(to.getDate() + 1); // make "to" inclusive of that whole day
|
||||
return { fromIso: from.toISOString(), toIso: to.toISOString() };
|
||||
}
|
||||
|
||||
const month = typeof query.month === "string" && /^\d{4}-\d{2}$/.test(query.month) ? query.month : null;
|
||||
const [year, monthIndex] = month
|
||||
? [Number(month.slice(0, 4)), Number(month.slice(5, 7)) - 1]
|
||||
: [now.getFullYear(), now.getMonth()];
|
||||
|
||||
const monthStart = new Date(year, monthIndex, 1);
|
||||
const monthEnd = new Date(year, monthIndex + 1, 1);
|
||||
|
||||
return { fromIso: monthStart.toISOString(), toIso: monthEnd.toISOString() };
|
||||
}
|
||||
22
backend/tsconfig.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": false,
|
||||
"sourceMap": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
BIN
branding/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
branding/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
branding/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
branding/favicon.ico
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
branding/icon-192.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
branding/icon-512.png
Normal file
|
After Width: | Height: | Size: 260 KiB |
BIN
branding/icon-maskable-512.png
Normal file
|
After Width: | Height: | Size: 193 KiB |
BIN
branding/logo-master.png
Normal file
|
After Width: | Height: | Size: 766 KiB |
BIN
branding/logo-source.jpg
Normal file
|
After Width: | Height: | Size: 29 KiB |
18
compose.yaml
Normal file
@@ -0,0 +1,18 @@
|
||||
services:
|
||||
eatme:
|
||||
image: eatme:latest
|
||||
container_name: eatme
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8092:4000"
|
||||
environment:
|
||||
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
|
||||
- ADMIN_EMAILS=${ADMIN_EMAILS}
|
||||
- JWT_SECRET=${JWT_SECRET}
|
||||
- CORS_ORIGIN=${CORS_ORIGIN}
|
||||
- COOKIE_SECURE=${COOKIE_SECURE}
|
||||
volumes:
|
||||
- eatme_data:/app/data
|
||||
|
||||
volumes:
|
||||
eatme_data:
|
||||
30
deploy/eatme.mipem.co
Normal file
@@ -0,0 +1,30 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name eatme.mipem.co;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
client_max_body_size 0;
|
||||
listen 443 ssl;
|
||||
server_name eatme.mipem.co;
|
||||
ssl on;
|
||||
ssl_certificate /etc/letsencrypt/live/eatme.mipem.co/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/eatme.mipem.co/privkey.pem;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
|
||||
# HSTS (ngx_http_headers_module is required) (63072000 seconds)
|
||||
add_header Strict-Transport-Security "max-age=63072000" always;
|
||||
|
||||
location / {
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Port 443;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_http_version 1.1;
|
||||
proxy_pass http://<deploy-host>:8092;
|
||||
proxy_redirect off;
|
||||
}
|
||||
}
|
||||
71
docs/api/admin-routes.md
Normal file
@@ -0,0 +1,71 @@
|
||||
---
|
||||
type: API Endpoint
|
||||
title: Admin routes
|
||||
description: Employee CRUD (add/soft-delete), org-wide and per-employee stats, and CSV export — all requireAdmin.
|
||||
resource: backend/src/routes/admin.ts
|
||||
tags: [api, admin]
|
||||
timestamp: 2026-08-12T00: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[] } // includes inactive (soft-deleted) rows
|
||||
```
|
||||
|
||||
```
|
||||
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="<email>_<YYYY-MM>.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 `<a href=... download>` — the session cookie rides
|
||||
along automatically since it's a same-origin GET.
|
||||
|
||||
# Related
|
||||
|
||||
- [employees](/docs/data-model/employees.md)
|
||||
- [attendance_events](/docs/data-model/attendance-events.md)
|
||||
- [Attendance routes](./attendance-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
|
||||
54
docs/api/attendance-routes.md
Normal file
@@ -0,0 +1,54 @@
|
||||
---
|
||||
type: API Endpoint
|
||||
title: Attendance routes
|
||||
description: POST /api/attendance/event, GET /api/attendance/state, GET /api/attendance/me — the employee's own clock in/out and stats.
|
||||
resource: backend/src/routes/attendance.ts
|
||||
tags: [api, attendance]
|
||||
timestamp: 2026-08-12T00:00:00Z
|
||||
---
|
||||
|
||||
# Attendance routes
|
||||
|
||||
Mounted at `/api/attendance`. Every route requires `requireAuth` +
|
||||
`requireEmployee` — admins with no employee row get `403` here, by design
|
||||
(the owner isn't necessarily clocking in themselves).
|
||||
|
||||
# Examples
|
||||
|
||||
```
|
||||
POST /api/attendance/event
|
||||
Body: { "type": "clock_in" | "clock_out" | "break_start" | "break_end" }
|
||||
201 -> { "event": AttendanceEvent, "status": LiveStatus }
|
||||
400 -> invalid type
|
||||
409 -> invalid transition for the current status, e.g. break_start while
|
||||
already clocked_out — { "error": string, "status": LiveStatus }
|
||||
```
|
||||
|
||||
```
|
||||
GET /api/attendance/state
|
||||
200 -> { "status": "clocked_out" | "working" | "on_break" }
|
||||
```
|
||||
|
||||
```
|
||||
GET /api/attendance/me?month=YYYY-MM (or ?from=YYYY-MM-DD&to=YYYY-MM-DD)
|
||||
Defaults to the current calendar month if no query params are given
|
||||
— see parseRange in backend/src/util/dateRange.ts.
|
||||
200 -> {
|
||||
"range": { "from": string, "to": string },
|
||||
"sessions": Session[],
|
||||
"totalWorkedMs": number,
|
||||
"totalBreakMs": number,
|
||||
"shiftCount": number
|
||||
}
|
||||
```
|
||||
|
||||
`Session` and the state machine behind these responses are documented at
|
||||
[attendance_events](/docs/data-model/attendance-events.md).
|
||||
|
||||
# Related
|
||||
|
||||
- [attendance_events](/docs/data-model/attendance-events.md)
|
||||
- [Admin routes](./admin-routes.md) — same stats shape, but for any employee
|
||||
- [Frontend stores](/docs/frontend/stores.md) — `attendanceStore` polls
|
||||
`GET /state` + `GET /me` every 60s and ticks a local clock every second
|
||||
between polls
|
||||
51
docs/api/auth-routes.md
Normal file
@@ -0,0 +1,51 @@
|
||||
---
|
||||
type: API Endpoint
|
||||
title: Auth routes
|
||||
description: POST /api/auth/google, POST /api/auth/logout, GET /api/auth/me.
|
||||
resource: backend/src/routes/auth.ts
|
||||
tags: [api, auth]
|
||||
timestamp: 2026-08-12T00:00:00Z
|
||||
---
|
||||
|
||||
# Auth routes
|
||||
|
||||
Mounted at `/api/auth` (`backend/src/index.ts`).
|
||||
|
||||
# Examples
|
||||
|
||||
```
|
||||
POST /api/auth/google
|
||||
Body: { "credential": "<google id token>" }
|
||||
200 -> { "user": SessionUser }
|
||||
400 -> missing credential
|
||||
401 -> invalid Google token
|
||||
403 -> email not verified, or not an admin and not an active employee
|
||||
```
|
||||
|
||||
Sets the `eatme_session` cookie on success. See
|
||||
[Auth flow](/docs/architecture/auth-flow.md) for what happens before this
|
||||
(role resolution) and what's inside the cookie.
|
||||
|
||||
```
|
||||
POST /api/auth/logout
|
||||
204, clears the session cookie. No auth required.
|
||||
```
|
||||
|
||||
```
|
||||
GET /api/auth/me
|
||||
Requires a valid session cookie (requireAuth).
|
||||
200 -> { "user": SessionUser }
|
||||
401 -> not authenticated
|
||||
```
|
||||
|
||||
`SessionUser` shape (`backend/src/types.ts`):
|
||||
|
||||
```ts
|
||||
{ email: string; name: string | null; role: "admin" | "employee"; employeeId: number | null }
|
||||
```
|
||||
|
||||
# Related
|
||||
|
||||
- [Auth flow](/docs/architecture/auth-flow.md)
|
||||
- [Attendance routes](./attendance-routes.md)
|
||||
- [Admin routes](./admin-routes.md)
|
||||
8
docs/api/index.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# API
|
||||
|
||||
Express routers under `/api/*`, mounted in `backend/src/index.ts`. All
|
||||
responses are JSON except the CSV export. Errors follow `{ "error": string }`.
|
||||
|
||||
- [Auth routes](./auth-routes.md) - `/api/auth/*` - Google sign-in, logout, current session
|
||||
- [Attendance routes](./attendance-routes.md) - `/api/attendance/*` - the employee's own clock in/out and stats
|
||||
- [Admin routes](./admin-routes.md) - `/api/admin/*` - employee management, org/individual stats, CSV export
|
||||
58
docs/architecture/auth-flow.md
Normal file
@@ -0,0 +1,58 @@
|
||||
---
|
||||
type: Architecture Overview
|
||||
title: Authentication & authorization
|
||||
description: Google ID-token verification, the session cookie, and the admin/employee role split.
|
||||
tags: [architecture, auth, security]
|
||||
timestamp: 2026-08-12T00:00:00Z
|
||||
---
|
||||
|
||||
# Authentication & authorization
|
||||
|
||||
## Sign-in
|
||||
|
||||
1. The frontend loads the Google Identity Services script
|
||||
(`https://accounts.google.com/gsi/client`, in `frontend/index.html`) and
|
||||
renders the button in `frontend/src/auth/GoogleButton.tsx`.
|
||||
2. Google returns a signed ID token to the browser (`credential`). The
|
||||
frontend does **not** trust it — it POSTs it to `POST /api/auth/google`.
|
||||
3. The backend verifies the token server-side with `google-auth-library`
|
||||
(`backend/src/auth/google.ts`, `verifyGoogleIdToken`), checking the
|
||||
audience against `GOOGLE_CLIENT_ID` and requiring `email_verified`.
|
||||
|
||||
## Role resolution
|
||||
|
||||
Two independent checks decide who gets in and as what role
|
||||
(`backend/src/routes/auth.ts`):
|
||||
|
||||
- **Admin**: the verified email is in the `ADMIN_EMAILS` env var
|
||||
(comma-separated, lowercased — `backend/src/env.ts`). Admins do **not**
|
||||
need a row in the `employees` table.
|
||||
- **Employee**: the verified email matches an **active** row in
|
||||
[employees](/docs/data-model/employees.md).
|
||||
|
||||
If neither matches, login is rejected with 403 ("This account is not
|
||||
registered as an EatMe employee"). This is how the employee whitelist is
|
||||
enforced — there's no self-service signup.
|
||||
|
||||
## Session
|
||||
|
||||
On success the backend signs its own JWT (`backend/src/auth/session.ts`,
|
||||
`issueSession`) containing `{ email, name, role, employeeId }`, and sets it
|
||||
as an httpOnly, `SameSite=Lax` cookie (`eatme_session`, 12h TTL). All
|
||||
subsequent `/api/*` requests carry that cookie
|
||||
(`frontend/src/api/client.ts` sends `credentials: 'include'`); the backend
|
||||
never re-derives role from the DB on every request — it trusts the signed
|
||||
cookie until it expires.
|
||||
|
||||
`backend/src/auth/middleware.ts` provides three guards used by the routers:
|
||||
|
||||
- `requireAuth` — valid session cookie present
|
||||
- `requireAdmin` — `role === 'admin'`
|
||||
- `requireEmployee` — `employeeId != null` (blocks admins with no employee
|
||||
profile from hitting `/api/attendance/*`)
|
||||
|
||||
## Related
|
||||
|
||||
- [System overview](./overview.md)
|
||||
- [Auth API](/docs/api/auth-routes.md)
|
||||
- [Employees table](/docs/data-model/employees.md)
|
||||
109
docs/architecture/deployment.md
Normal file
@@ -0,0 +1,109 @@
|
||||
---
|
||||
type: Architecture Overview
|
||||
title: Build & deployment
|
||||
description: esbuild backend bundle, Vite frontend build, the multi-stage Docker image, and how it ships to a host.
|
||||
tags: [architecture, deployment, docker]
|
||||
timestamp: 2026-08-12T12:00:00Z
|
||||
---
|
||||
|
||||
# Build & deployment
|
||||
|
||||
## Backend build
|
||||
|
||||
`backend/package.json`'s `build` script runs `tsc --noEmit` (typecheck only)
|
||||
then `node esbuild.config.js`, which bundles `src/index.ts` into a single
|
||||
minified `dist/index.js` (~1.4MB, with sourcemap). `better-sqlite3` is
|
||||
marked `external` in `esbuild.config.js` because it ships a native `.node`
|
||||
binding that can't be bundled — it stays a real `node_modules` dependency at
|
||||
runtime.
|
||||
|
||||
## Frontend build
|
||||
|
||||
Standard Vite build (`frontend/package.json` → `tsc -b && vite build`),
|
||||
producing an optimized static `dist/` with a service worker
|
||||
(`vite-plugin-pwa`, `frontend/vite.config.ts`). `VITE_*` env vars (just
|
||||
`VITE_GOOGLE_CLIENT_ID`) are inlined at build time — they cannot be changed
|
||||
at container runtime, only at image-build time.
|
||||
|
||||
## Docker image
|
||||
|
||||
The root `Dockerfile` is a three-stage build:
|
||||
|
||||
1. `frontend-build` — `npm ci` + `npm run build` in `frontend/`. Takes
|
||||
`VITE_GOOGLE_CLIENT_ID` as a build arg.
|
||||
2. `backend-build` — `npm ci` + `npm run build` in `backend/`, producing the
|
||||
esbuild bundle.
|
||||
3. `runtime` — fresh `npm ci --omit=dev` for `backend/package.json` on the
|
||||
**same base image** as `backend-build` (so `better-sqlite3`'s native
|
||||
binding is compiled for the environment it'll actually run in, rather
|
||||
than copied from a different stage). Then copies in the backend bundle
|
||||
(`dist/index.js`) and the frontend build output as `dist/public/`.
|
||||
|
||||
At runtime, `backend/src/index.ts` checks whether `dist/public` exists next
|
||||
to itself and, if so, serves it with `express.static` plus a SPA fallback
|
||||
route (`app.get(/^(?!\/api\/).*/, ...)`) — that's what makes the single
|
||||
image serve both API and UI on one origin. In local dev this directory
|
||||
doesn't exist, so the branch is skipped and the frontend's own Vite dev
|
||||
server is used instead.
|
||||
|
||||
```bash
|
||||
docker build --build-arg VITE_GOOGLE_CLIENT_ID=<id> -t eatme .
|
||||
docker run -d -p 4000:4000 -v eatme-data:/app/data \
|
||||
-e GOOGLE_CLIENT_ID=<id> -e ADMIN_EMAILS=<emails> \
|
||||
-e JWT_SECRET=<secret> -e CORS_ORIGIN=<origin> -e COOKIE_SECURE=true \
|
||||
eatme
|
||||
```
|
||||
|
||||
The SQLite file lives at `DB_PATH` (default `/app/data/eatme.db` in the
|
||||
image) — mount `/app/data` as a volume or it's lost on container recreate.
|
||||
|
||||
## Shipping to a host
|
||||
|
||||
The image is always built where its target platform matches the deploy
|
||||
host, and shipped as a built artifact — never by copying the repo to the
|
||||
server and building there (keeps source off prod hosts, no build toolchain
|
||||
needed remotely, and the exact artifact tested locally is what runs). If
|
||||
building on an Apple Silicon Mac for an x86_64 server, `--platform
|
||||
linux/amd64` is required or the image won't run there at all.
|
||||
|
||||
```bash
|
||||
docker build --platform linux/amd64 \
|
||||
--build-arg VITE_GOOGLE_CLIENT_ID=<id> -t eatme:latest .
|
||||
docker save eatme:latest | gzip | ssh <host> "gunzip | docker load"
|
||||
```
|
||||
|
||||
Only `compose.yaml` (root of the repo) and a `.env` next to it (see
|
||||
`.env.example`) then need to reach the host — `compose.yaml` references
|
||||
`image: eatme:latest` (not `build:`), so `docker compose up -d` just starts
|
||||
the already-loaded image. Redeploying a new version is the same
|
||||
build → save → load → `docker compose up -d` sequence again.
|
||||
|
||||
```bash
|
||||
scp compose.yaml <host>:~/eatme/compose.yaml
|
||||
ssh <host> "cd ~/eatme && docker compose up -d"
|
||||
```
|
||||
|
||||
TLS termination is handled by an nginx reverse proxy in front of the
|
||||
container (not part of this repo) — a template server block lives at
|
||||
`deploy/<domain>` for whichever host runs nginx, proxying
|
||||
`https://<domain>` to `http://<deploy-host>:<published-port>`. It's a plain
|
||||
file to be moved into `/etc/nginx/sites-available/` (and symlinked into
|
||||
`sites-enabled/`) by whoever has sudo on that host — nothing here writes to
|
||||
system nginx config directly.
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Var | App | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `GOOGLE_CLIENT_ID` | backend | yes | must match frontend's `VITE_GOOGLE_CLIENT_ID` |
|
||||
| `ADMIN_EMAILS` | backend | yes | comma-separated |
|
||||
| `JWT_SECRET` | backend | yes | session signing key |
|
||||
| `DB_PATH` | backend | no | default `./data/eatme.db` |
|
||||
| `CORS_ORIGIN` | backend | no | default `http://localhost:5173` |
|
||||
| `COOKIE_SECURE` | backend | no | set `true` behind HTTPS |
|
||||
| `PORT` | backend | no | default `4000` |
|
||||
| `VITE_GOOGLE_CLIENT_ID` | frontend | yes | build-time only |
|
||||
|
||||
## Related
|
||||
|
||||
- [System overview](./overview.md)
|
||||
5
docs/architecture/index.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Architecture
|
||||
|
||||
- [System overview](./overview.md) - how the backend, frontend, and database fit together
|
||||
- [Authentication & authorization](./auth-flow.md) - Google ID-token verification, session cookie, admin/employee roles
|
||||
- [Build & deployment](./deployment.md) - esbuild bundle, Vite build, multi-stage Dockerfile, env vars
|
||||
51
docs/architecture/overview.md
Normal file
@@ -0,0 +1,51 @@
|
||||
---
|
||||
type: Architecture Overview
|
||||
title: System overview
|
||||
description: How the EatMe frontend, backend, and database fit together.
|
||||
tags: [architecture, backend, frontend]
|
||||
timestamp: 2026-08-12T00:00:00Z
|
||||
---
|
||||
|
||||
# System overview
|
||||
|
||||
EatMe is a small monorepo with two independently-runnable apps that ship as
|
||||
one Docker image in production.
|
||||
|
||||
- **`backend/`** — Express + TypeScript API, SQLite via `better-sqlite3`
|
||||
(entry point `backend/src/index.ts`). Owns all state: employee whitelist,
|
||||
attendance events, sessions.
|
||||
- **`frontend/`** — Vite + React + TypeScript PWA (entry point
|
||||
`frontend/src/App.tsx`). Talks to the backend only over `/api/*`; in dev,
|
||||
Vite proxies that path to `http://localhost:4000` (see
|
||||
`frontend/vite.config.ts`).
|
||||
|
||||
## Request flow
|
||||
|
||||
1. Employee/admin signs in with Google Identity Services in the browser
|
||||
(ID-token flow, no server-side OAuth redirect) — see
|
||||
[Auth flow](./auth-flow.md).
|
||||
2. The frontend POSTs the Google ID token to `POST /api/auth/google`; the
|
||||
backend verifies it, issues its own JWT session cookie, and from then on
|
||||
every `/api/*` call rides on that cookie (`credentials: 'include'` in
|
||||
`frontend/src/api/client.ts`).
|
||||
3. All app logic (fetching, polling, derived state) lives in Zustand stores
|
||||
under `frontend/src/store/` — see [Frontend](/docs/frontend/index.md).
|
||||
Components are thin consumers of store selectors/actions.
|
||||
4. In production the backend also serves the built frontend as static files
|
||||
(`dist/public/`, see [Deployment](./deployment.md)) — one process, one
|
||||
container, one origin.
|
||||
|
||||
## Design language
|
||||
|
||||
The UI follows the "light monochrome TUI" style defined at
|
||||
`~/doc/concepts/ui/light-mono-tui.md`: grey page background, black type and
|
||||
1px borders, mono font, inverted fills for active/hover state, no accent
|
||||
colors, no border-radius. Implemented as plain CSS tokens in
|
||||
`frontend/src/styles/tui.css`.
|
||||
|
||||
## Related
|
||||
|
||||
- [Auth flow](./auth-flow.md)
|
||||
- [Deployment](./deployment.md)
|
||||
- [Data model](/docs/data-model/index.md)
|
||||
- [API](/docs/api/index.md)
|
||||
68
docs/data-model/attendance-events.md
Normal file
@@ -0,0 +1,68 @@
|
||||
---
|
||||
type: SQLite Table
|
||||
title: attendance_events
|
||||
description: Append-only log of clock_in/clock_out/break_start/break_end events, and the state machine built on top of it.
|
||||
resource: backend/src/db/index.ts
|
||||
tags: [data-model, sqlite]
|
||||
timestamp: 2026-08-12T00:00:00Z
|
||||
---
|
||||
|
||||
# attendance_events
|
||||
|
||||
Append-only event log — there is no "shift" or "session" row. Shifts are
|
||||
derived at read time by walking events chronologically
|
||||
(`buildSessions` in `backend/src/services/attendance.ts`).
|
||||
|
||||
# Schema
|
||||
|
||||
| Column | Type | Description |
|
||||
|---|---|---|
|
||||
| `id` | INTEGER PK | autoincrement |
|
||||
| `employee_id` | INTEGER | references [employees.id](./employees.md) |
|
||||
| `type` | TEXT | one of `clock_in`, `clock_out`, `break_start`, `break_end` (CHECK constraint) |
|
||||
| `ts` | TEXT | ISO 8601 UTC, set by SQLite default |
|
||||
|
||||
Indexed on `(employee_id, ts)`.
|
||||
|
||||
# State machine
|
||||
|
||||
`getLiveStatus` derives one of three states from an employee's most recent
|
||||
event:
|
||||
|
||||
| Last event | Status |
|
||||
|---|---|
|
||||
| none / `clock_out` | `clocked_out` |
|
||||
| `clock_in` / `break_end` | `working` |
|
||||
| `break_start` | `on_break` |
|
||||
|
||||
`recordEvent` only allows the transition that's valid from the current
|
||||
status (`NEXT_ALLOWED` map) — e.g. you can't `break_start` while already
|
||||
`clocked_out`. An invalid attempt throws `InvalidTransitionError`, surfaced
|
||||
by the API as `409`.
|
||||
|
||||
```
|
||||
clocked_out --clock_in--> working
|
||||
working --break_start--> on_break
|
||||
on_break --break_end--> working
|
||||
working --clock_out--> clocked_out
|
||||
```
|
||||
|
||||
# Deriving shifts
|
||||
|
||||
`buildSessions(events, now)` walks a chronological event list and pairs
|
||||
`clock_in`...`clock_out` into a `Session`, with nested `breaks`. A session
|
||||
still missing its `clock_out` is `open: true` and its `workedMs`/`breakMs`
|
||||
are computed against `now` (the request time) rather than a real end —
|
||||
that's why the frontend re-derives it against a live clock between polls
|
||||
(see [Frontend stores](/docs/frontend/stores.md)) instead of trusting a
|
||||
stale fetch forever.
|
||||
|
||||
`summarize(events, now)` reduces sessions into `totalWorkedMs`,
|
||||
`totalBreakMs`, `shiftCount` for a date range — the shape returned by both
|
||||
the employee's own stats endpoint and the admin per-employee endpoint (see
|
||||
[Attendance API](/docs/api/attendance-routes.md), [Admin API](/docs/api/admin-routes.md)).
|
||||
|
||||
# Related
|
||||
|
||||
- [employees](./employees.md)
|
||||
- [Attendance API](/docs/api/attendance-routes.md)
|
||||
40
docs/data-model/employees.md
Normal file
@@ -0,0 +1,40 @@
|
||||
---
|
||||
type: SQLite Table
|
||||
title: employees
|
||||
description: The whitelist of employee emails allowed to log attendance, with a soft-delete active flag.
|
||||
resource: backend/src/db/index.ts
|
||||
tags: [data-model, sqlite]
|
||||
timestamp: 2026-08-12T00:00:00Z
|
||||
---
|
||||
|
||||
# employees
|
||||
|
||||
The employee whitelist. A row here (with `active = 1`) is what lets a Google
|
||||
account log in as an employee — see [Auth flow](/docs/architecture/auth-flow.md).
|
||||
Admins are **not** rows in this table; they're resolved purely from the
|
||||
`ADMIN_EMAILS` env var.
|
||||
|
||||
# Schema
|
||||
|
||||
| Column | Type | Description |
|
||||
|---|---|---|
|
||||
| `id` | INTEGER PK | autoincrement |
|
||||
| `email` | TEXT | unique, lowercased on write |
|
||||
| `name` | TEXT | nullable; filled in from the Google profile on first login (`touchEmployeeName` in `backend/src/services/employees.ts`), only if still null |
|
||||
| `active` | INTEGER | 1 = can log in, 0 = soft-deleted |
|
||||
| `created_at` | TEXT | ISO 8601 UTC, set by SQLite default |
|
||||
|
||||
# Behavior
|
||||
|
||||
- **Add** (`addEmployee`): upsert by email. If a soft-deleted row exists for
|
||||
that email, it's reactivated (`active = 1`) rather than duplicated.
|
||||
- **Remove** (`deactivateEmployee`): sets `active = 0`. This is a **soft**
|
||||
delete by design — [attendance_events](./attendance-events.md) rows keep
|
||||
referencing the employee, so historical stats for a removed employee are
|
||||
still computable and re-adding the same email restores access without
|
||||
losing history.
|
||||
|
||||
# Related
|
||||
|
||||
- [attendance_events](./attendance-events.md) — `employee_id` references this table
|
||||
- [Employees API](/docs/api/admin-routes.md)
|
||||
6
docs/data-model/index.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# Data model
|
||||
|
||||
SQLite via `better-sqlite3`, schema created on boot in `backend/src/db/index.ts`.
|
||||
|
||||
- [employees](./employees.md) - the whitelist of employee emails, with a soft-delete active flag
|
||||
- [attendance_events](./attendance-events.md) - append-only clock-in/out/break log and the derived state machine
|
||||
45
docs/frontend/design-system.md
Normal file
@@ -0,0 +1,45 @@
|
||||
---
|
||||
type: Design Language
|
||||
title: TUI monochrome design system
|
||||
description: How the light-mono-tui design language is implemented in this app's CSS.
|
||||
resource: frontend/src/styles/tui.css
|
||||
tags: [frontend, design, css]
|
||||
timestamp: 2026-08-12T00:00:00Z
|
||||
---
|
||||
|
||||
# TUI monochrome design system
|
||||
|
||||
The full design language spec lives outside this bundle at
|
||||
`~/doc/concepts/ui/light-mono-tui.md` (grey background, black type/borders,
|
||||
mono font, inverted active/hover states, no accent color, no radius, no
|
||||
shadow). This doc covers how EatMe implements it.
|
||||
|
||||
`frontend/src/styles/tui.css` defines the tokens (`--bg`, `--fg`, `--muted`,
|
||||
`--hover`, `--empty`, `--panel`, `--font`) and the component classes built on
|
||||
them: `.tabs`/`.tab`, `.btn`/`.btn-block`/`.btn-ghost`, `.panel`,
|
||||
`.field-row`, `.list`/`.row`, `.badge` (`-solid`, `-dashed`, `-double`),
|
||||
`.stat-grid`/`.stat-tile`, `.login-card`.
|
||||
|
||||
# App-specific additions
|
||||
|
||||
Built for this app, following the same rules (square edges, monochrome,
|
||||
1px borders) rather than introducing new visual language:
|
||||
|
||||
- **`.bar-chart-*`** (`frontend/src/components/BarChart.tsx`) — the
|
||||
per-employee daily-hours chart. Y-axis labels + a repeating 1px
|
||||
`--empty`-colored gridline background at 25% steps computed from a
|
||||
"nice" rounded max (`Math.ceil` to the next whole hour); bars are solid
|
||||
`--fg`, `--muted` on hover; day-of-month labels render in a *separate*
|
||||
flex row below the fixed-height bar track (not nested inside each bar's
|
||||
percentage-height column) specifically to avoid the bars visually
|
||||
overlapping the labels.
|
||||
- **`.month-nav`** (`frontend/src/components/MonthNav.tsx`) — `[<] label [>]`
|
||||
bordered strip, styled like the design language's tab strip. Deliberately
|
||||
placed *inside* the employee-detail panel next to the chart it controls,
|
||||
not as a page-level control — an earlier version put it at the top of the
|
||||
whole admin stats page, which tested as confusing ("can't browse history
|
||||
on the chart") since it was visually disconnected from what it affected.
|
||||
|
||||
# Related
|
||||
|
||||
- [Zustand stores](./stores.md)
|
||||
6
docs/frontend/index.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# Frontend
|
||||
|
||||
Vite + React + TypeScript PWA, Google Identity Services for sign-in.
|
||||
|
||||
- [Zustand stores](./stores.md) - authStore, attendanceStore, adminStore — where all app logic lives
|
||||
- [TUI monochrome design system](./design-system.md) - the light-mono-tui design language and this app's chart/nav additions
|
||||
62
docs/frontend/stores.md
Normal file
@@ -0,0 +1,62 @@
|
||||
---
|
||||
type: Frontend Module
|
||||
title: Zustand stores
|
||||
description: authStore, attendanceStore, and adminStore — where all app/fetch logic lives, keeping components thin.
|
||||
resource: frontend/src/store
|
||||
tags: [frontend, zustand, state]
|
||||
timestamp: 2026-08-12T00:00:00Z
|
||||
---
|
||||
|
||||
# Zustand stores
|
||||
|
||||
Convention (matching the user's other `~/mywork` projects): one flat
|
||||
`create<State>()` per concern in `frontend/src/store/<name>Store.ts`,
|
||||
default-exported as `use<Name>Store`. State and actions live together;
|
||||
async/API calls are colocated directly in the actions via `set`/`get` — no
|
||||
separate service layer. No `persist`/`devtools`/`immer`/slices are used
|
||||
here. Components read via selectors (`useXStore(s => s.field)`) and call
|
||||
actions; they don't own fetch/interval logic themselves.
|
||||
|
||||
# `authStore.ts`
|
||||
|
||||
`{ user, loading, loginError }` + `init()` (calls `GET /auth/me` once on
|
||||
app mount, from `App.tsx`), `loginWithGoogle(credential)`, `logout()`. No
|
||||
persistence — the real session lives in the httpOnly cookie, so on reload
|
||||
the store just re-asks the backend via `init()`.
|
||||
|
||||
# `attendanceStore.ts`
|
||||
|
||||
Employee's own clock state. `{ status, stats, live, busy, error }` plus
|
||||
`load()`, `recordEvent(type)`, `startPolling()`/`stopPolling()`.
|
||||
|
||||
- `startPolling` sets two intervals: one that calls `load()` every 60s
|
||||
(keeps the server truth in sync), and one that recomputes `live` every
|
||||
1s from the last-fetched `stats` against a fresh `Date` — via
|
||||
`withLiveTime` (`frontend/src/lib/liveSession.ts`) — so an open shift's
|
||||
duration counts up smoothly instead of freezing until the next poll.
|
||||
`EmployeeApp.tsx` starts/stops this based on `status` (only polls while
|
||||
`working`/`on_break`).
|
||||
- **Gotcha this hit in practice**: `live` is a plain cached field, updated
|
||||
by `set()` inside the tick/poll callbacks — it is deliberately *not* a
|
||||
selector method like `liveStats: () => withLiveTime(...)` called as
|
||||
`useAttendanceStore(s => s.liveStats())`. That pattern returns a new
|
||||
object on every call, which breaks React's `useSyncExternalStore`
|
||||
(used internally by zustand v5) — "Maximum update depth exceeded" from an
|
||||
infinite render loop, since the snapshot is never referentially stable
|
||||
between renders even when nothing actually changed.
|
||||
|
||||
# `adminStore.ts`
|
||||
|
||||
Employee list + org/individual stats + the shared `month` ("YYYY-MM") used
|
||||
by both the summary table and the selected employee's detail chart.
|
||||
`setMonth`/`prevMonth`/`nextMonth` update `month` and re-fetch `loadStats()`
|
||||
plus (if an employee is selected) `selectEmployee()` together, so the
|
||||
summary and the detail chart always show the same period. `removeEmployee`
|
||||
calls the soft-delete endpoint — see [employees](/docs/data-model/employees.md).
|
||||
|
||||
# Related
|
||||
|
||||
- [attendance_events](/docs/data-model/attendance-events.md) — the state
|
||||
machine and `Session` shape these stores fetch
|
||||
- [Attendance API](/docs/api/attendance-routes.md)
|
||||
- [Admin API](/docs/api/admin-routes.md)
|
||||
14
docs/index.md
Normal file
@@ -0,0 +1,14 @@
|
||||
---
|
||||
okf_version: "0.1"
|
||||
---
|
||||
|
||||
# EatMe — Docházka
|
||||
|
||||
Knowledge bundle for the EatMe bistro employee attendance-tracking system: a
|
||||
Google-SSO PWA where employees log clock-in/out and breaks, and the owner
|
||||
(admin) manages the employee whitelist and views statistics.
|
||||
|
||||
- [Architecture](./architecture/) — system overview, auth flow, deployment
|
||||
- [Data model](./data-model/) — SQLite tables and the attendance state machine
|
||||
- [API](./api/) — HTTP routes under `/api/auth`, `/api/attendance`, `/api/admin`
|
||||
- [Frontend](./frontend/) — Zustand stores and the TUI monochrome design system
|
||||
10
docs/log.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# Update Log
|
||||
|
||||
## 2026-08-12
|
||||
- **Update**: Documented the actual production shipping workflow in
|
||||
[Build & deployment](./architecture/deployment.md) — build locally for
|
||||
the target platform, `docker save`/`load` over ssh (never copy source to
|
||||
the server), ship only `compose.yaml` + `.env`, nginx reverse proxy
|
||||
template in `deploy/`.
|
||||
- **Creation**: Initial OKF bundle documenting the EatMe attendance system —
|
||||
architecture, data model, API surface, frontend structure, and deployment.
|
||||
2
frontend/.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
# Must match the backend's GOOGLE_CLIENT_ID.
|
||||
VITE_GOOGLE_CLIENT_ID=your-google-client-id.apps.googleusercontent.com
|
||||
26
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
dev-dist
|
||||
*.local
|
||||
.env
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
8
frontend/.oxlintrc.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "typescript", "oxc"],
|
||||
"rules": {
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||
}
|
||||
}
|
||||
17
frontend/index.html
Normal file
@@ -0,0 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="cs">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta name="description" content="Evidence docházky zaměstnanců bistra EatMe" />
|
||||
<title>EatMe — Docházka</title>
|
||||
<script src="https://accounts.google.com/gsi/client" async defer></script>
|
||||
</head>
|
||||
<body class="app">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
27
frontend/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "eatme-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "oxlint",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.13.3",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.4",
|
||||
"oxlint": "^1.75.0",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.2.0",
|
||||
"vite-plugin-pwa": "^1.3.0"
|
||||
}
|
||||
}
|
||||
BIN
frontend/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
frontend/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
frontend/public/icon-192.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
frontend/public/icon-512.png
Normal file
|
After Width: | Height: | Size: 260 KiB |
BIN
frontend/public/icon-maskable-512.png
Normal file
|
After Width: | Height: | Size: 193 KiB |
50
frontend/src/App.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useEffect } from 'react';
|
||||
import useAuthStore from './store/authStore';
|
||||
import { LoginPage } from './pages/LoginPage';
|
||||
import { EmployeeApp } from './pages/EmployeeApp';
|
||||
import { AdminApp } from './pages/AdminApp';
|
||||
|
||||
function App() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const loading = useAuthStore((s) => s.loading);
|
||||
const logout = useAuthStore((s) => s.logout);
|
||||
const init = useAuthStore((s) => s.init);
|
||||
|
||||
useEffect(() => {
|
||||
init();
|
||||
}, [init]);
|
||||
|
||||
if (loading) {
|
||||
return <div className="empty-line">> načítám…</div>;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <LoginPage />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<header className="chrome">
|
||||
<div className="chrome-inner">
|
||||
<div>
|
||||
<span className="brand">EatMe</span>
|
||||
<span className="brand-sub">docházka</span>
|
||||
</div>
|
||||
<div className="chrome-user">
|
||||
<span>
|
||||
{user.name ?? user.email} · {user.role === 'admin' ? 'admin' : 'zaměstnanec'}
|
||||
</span>
|
||||
<button className="btn-ghost" onClick={logout}>
|
||||
odhlásit [x]
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main className="scroll">
|
||||
<div className="scroll-inner">{user.role === 'admin' ? <AdminApp /> : <EmployeeApp />}</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
38
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`/api${path}`, {
|
||||
...init,
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...init?.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
const body = await res.json().catch(() => ({}));
|
||||
|
||||
if (!res.ok) {
|
||||
throw new ApiError(res.status, body.error ?? `Request failed (${res.status})`);
|
||||
}
|
||||
|
||||
return body as T;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string) => request<T>(path),
|
||||
post: <T>(path: string, data?: unknown) =>
|
||||
request<T>(path, { method: 'POST', body: data ? JSON.stringify(data) : undefined }),
|
||||
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
|
||||
};
|
||||
55
frontend/src/api/types.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
export type EventType = 'clock_in' | 'clock_out' | 'break_start' | 'break_end';
|
||||
export type LiveStatus = 'clocked_out' | 'working' | 'on_break';
|
||||
export type Role = 'admin' | 'employee';
|
||||
|
||||
export interface SessionUser {
|
||||
email: string;
|
||||
name: string | null;
|
||||
role: Role;
|
||||
employeeId: number | null;
|
||||
}
|
||||
|
||||
export interface Employee {
|
||||
id: number;
|
||||
email: string;
|
||||
name: string | null;
|
||||
active: 0 | 1;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AttendanceEvent {
|
||||
id: number;
|
||||
employee_id: number;
|
||||
type: EventType;
|
||||
ts: string;
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
clockIn: string;
|
||||
clockOut: string | null;
|
||||
breaks: { start: string; end: string | null }[];
|
||||
workedMs: number;
|
||||
breakMs: number;
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
export interface StatsSummary {
|
||||
range: { from: string; to: string };
|
||||
sessions: Session[];
|
||||
totalWorkedMs: number;
|
||||
totalBreakMs: number;
|
||||
shiftCount: number;
|
||||
}
|
||||
|
||||
export interface EmployeeStat {
|
||||
employee: Employee;
|
||||
totalWorkedMs: number;
|
||||
totalBreakMs: number;
|
||||
shiftCount: number;
|
||||
}
|
||||
|
||||
export interface AdminStatsSummary {
|
||||
range: { from: string; to: string };
|
||||
totals: { totalWorkedMs: number; totalBreakMs: number; shiftCount: number };
|
||||
employees: EmployeeStat[];
|
||||
}
|
||||
44
frontend/src/auth/GoogleButton.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import useAuthStore from '../store/authStore';
|
||||
|
||||
export function GoogleButton() {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const loginWithGoogle = useAuthStore((s) => s.loginWithGoogle);
|
||||
|
||||
useEffect(() => {
|
||||
const clientId = import.meta.env.VITE_GOOGLE_CLIENT_ID;
|
||||
let cancelled = false;
|
||||
|
||||
function render() {
|
||||
if (cancelled || !ref.current || !window.google) return;
|
||||
window.google.accounts.id.initialize({
|
||||
client_id: clientId,
|
||||
callback: (response) => loginWithGoogle(response.credential),
|
||||
});
|
||||
window.google.accounts.id.renderButton(ref.current, {
|
||||
theme: 'outline',
|
||||
size: 'large',
|
||||
text: 'signin_with',
|
||||
shape: 'square',
|
||||
width: 280,
|
||||
});
|
||||
}
|
||||
|
||||
if (window.google) {
|
||||
render();
|
||||
} else {
|
||||
const interval = setInterval(() => {
|
||||
if (window.google) {
|
||||
clearInterval(interval);
|
||||
render();
|
||||
}
|
||||
}, 100);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}
|
||||
}, [loginWithGoogle]);
|
||||
|
||||
return <div ref={ref} className="google-btn-slot" />;
|
||||
}
|
||||
99
frontend/src/components/AdminStats.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { useEffect } from 'react';
|
||||
import useAdminStore from '../store/adminStore';
|
||||
import { StatsSummary } from './StatsSummary';
|
||||
import { SessionList } from './SessionList';
|
||||
import { MonthNav } from './MonthNav';
|
||||
import { BarChart } from './BarChart';
|
||||
import { formatDuration } from '../lib/format';
|
||||
import { formatMonthLabel } from '../lib/month';
|
||||
import { aggregateDailyWorkedMs } from '../lib/dailyAggregate';
|
||||
|
||||
export function AdminStats() {
|
||||
const stats = useAdminStore((s) => s.stats);
|
||||
const month = useAdminStore((s) => s.month);
|
||||
const selectedEmployeeId = useAdminStore((s) => s.selectedEmployeeId);
|
||||
const detail = useAdminStore((s) => s.employeeDetail);
|
||||
const detailLoading = useAdminStore((s) => s.detailLoading);
|
||||
const loadStats = useAdminStore((s) => s.loadStats);
|
||||
const selectEmployee = useAdminStore((s) => s.selectEmployee);
|
||||
const prevMonth = useAdminStore((s) => s.prevMonth);
|
||||
const nextMonth = useAdminStore((s) => s.nextMonth);
|
||||
|
||||
useEffect(() => {
|
||||
loadStats();
|
||||
}, [loadStats]);
|
||||
|
||||
if (!stats) return <div className="empty-line">> načítám…</div>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="panel">
|
||||
<p className="panel-title">Souhrn · {formatMonthLabel(month)}</p>
|
||||
<StatsSummary
|
||||
totalWorkedMs={stats.totals.totalWorkedMs}
|
||||
totalBreakMs={stats.totals.totalBreakMs}
|
||||
shiftCount={stats.totals.shiftCount}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="panel">
|
||||
<p className="panel-title">Podle zaměstnance · klikni pro detail</p>
|
||||
<div className="list">
|
||||
{stats.employees.length === 0 && (
|
||||
<div className="empty-line">· zatím žádní zaměstnanci</div>
|
||||
)}
|
||||
{stats.employees.map((e) => (
|
||||
<div
|
||||
className="row"
|
||||
key={e.employee.id}
|
||||
onClick={() => selectEmployee(e.employee.id)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<div className="row-main">
|
||||
<div className="row-title">{e.employee.name ?? e.employee.email}</div>
|
||||
<div className="row-meta">
|
||||
{e.shiftCount} směn · pauzy {formatDuration(e.totalBreakMs)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="row-action">
|
||||
<span className="badge badge-solid">{formatDuration(e.totalWorkedMs)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedEmployeeId != null && (
|
||||
<div className="panel">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
|
||||
<p className="panel-title">
|
||||
Detail ·{' '}
|
||||
{stats.employees.find((e) => e.employee.id === selectedEmployeeId)?.employee.name ??
|
||||
stats.employees.find((e) => e.employee.id === selectedEmployeeId)?.employee.email}
|
||||
</p>
|
||||
<a
|
||||
className="btn"
|
||||
href={`/api/admin/stats/${selectedEmployeeId}/export?month=${month}`}
|
||||
download
|
||||
>
|
||||
Export CSV
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<MonthNav month={month} onPrev={prevMonth} onNext={nextMonth} />
|
||||
|
||||
{detailLoading && !detail && <div className="empty-line">> načítám…</div>}
|
||||
|
||||
{detail && (
|
||||
<>
|
||||
<BarChart values={aggregateDailyWorkedMs(detail.sessions, month)} month={month} />
|
||||
<div style={{ marginTop: '1rem' }}>
|
||||
<SessionList sessions={detail.sessions} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
59
frontend/src/components/BarChart.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { formatDuration } from '../lib/format';
|
||||
|
||||
interface Props {
|
||||
/** Worked ms per day, index 0 = day 1 of the shown month. */
|
||||
values: number[];
|
||||
month: string;
|
||||
}
|
||||
|
||||
const HOUR_MS = 3_600_000;
|
||||
|
||||
export function BarChart({ values, month }: Props) {
|
||||
const maxHours = Math.max(...values) / HOUR_MS;
|
||||
const niceMaxHours = Math.max(1, Math.ceil(maxHours));
|
||||
const niceMaxMs = niceMaxHours * HOUR_MS;
|
||||
const [year, monthIndex] = month.split('-').map(Number);
|
||||
|
||||
const yTicks = [4, 3, 2, 1, 0].map((n) => (niceMaxMs * n) / 4);
|
||||
|
||||
return (
|
||||
<div className="bar-chart-wrap">
|
||||
<div className="bar-chart-yaxis">
|
||||
{yTicks.map((ms) => (
|
||||
<span key={ms}>{formatDuration(ms)}</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="bar-chart-body">
|
||||
<div
|
||||
className="bar-chart bar-chart-grid"
|
||||
role="img"
|
||||
aria-label={`Odpracované hodiny podle dne v měsíci ${month}`}
|
||||
>
|
||||
{values.map((ms, i) => {
|
||||
const day = i + 1;
|
||||
const heightPct = (ms / niceMaxMs) * 100;
|
||||
const label = new Date(year, monthIndex - 1, day).toLocaleDateString('cs-CZ', {
|
||||
day: 'numeric',
|
||||
month: 'numeric',
|
||||
});
|
||||
return (
|
||||
<div className="bar-col" key={day} title={`${label}: ${formatDuration(ms)}`}>
|
||||
<div className="bar" style={{ height: `${heightPct}%` }} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="bar-chart-labels">
|
||||
{values.map((_, i) => {
|
||||
const day = i + 1;
|
||||
return (
|
||||
<div className="bar-col-label" key={day}>
|
||||
{day === 1 || day % 5 === 0 ? day : ''}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
frontend/src/components/ClockControls.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { EventType, LiveStatus } from '../api/types';
|
||||
|
||||
interface Props {
|
||||
status: LiveStatus;
|
||||
busy: boolean;
|
||||
onAction: (type: EventType) => void;
|
||||
}
|
||||
|
||||
export function ClockControls({ status, busy, onAction }: Props) {
|
||||
if (status === 'clocked_out') {
|
||||
return (
|
||||
<button className="btn btn-block" disabled={busy} onClick={() => onAction('clock_in')}>
|
||||
Příchod
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'working') {
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<button
|
||||
className="btn btn-block"
|
||||
disabled={busy}
|
||||
onClick={() => onAction('break_start')}
|
||||
>
|
||||
Start pauzy
|
||||
</button>
|
||||
<button className="btn btn-block" disabled={busy} onClick={() => onAction('clock_out')}>
|
||||
Odchod
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button className="btn btn-block" disabled={busy} onClick={() => onAction('break_end')}>
|
||||
Konec pauzy
|
||||
</button>
|
||||
);
|
||||
}
|
||||
72
frontend/src/components/EmployeeManager.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import useAdminStore from '../store/adminStore';
|
||||
import { formatDate } from '../lib/format';
|
||||
|
||||
export function EmployeeManager() {
|
||||
const employees = useAdminStore((s) => s.employees);
|
||||
const error = useAdminStore((s) => s.employeesError);
|
||||
const busy = useAdminStore((s) => s.employeesBusy);
|
||||
const loadEmployees = useAdminStore((s) => s.loadEmployees);
|
||||
const addEmployee = useAdminStore((s) => s.addEmployee);
|
||||
const removeEmployee = useAdminStore((s) => s.removeEmployee);
|
||||
|
||||
const [email, setEmail] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
loadEmployees();
|
||||
}, [loadEmployees]);
|
||||
|
||||
async function handleAdd(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!email.trim()) return;
|
||||
await addEmployee(email.trim());
|
||||
setEmail('');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="panel">
|
||||
<p className="panel-title">Zaměstnanci · přístup přes Google účet</p>
|
||||
|
||||
<form className="field-row" onSubmit={handleAdd}>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="> email@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={busy}
|
||||
/>
|
||||
<button className="btn" type="submit" disabled={busy}>
|
||||
Přidat
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{error && <div className="login-error" style={{ marginTop: '0.75rem' }}>{error}</div>}
|
||||
|
||||
<div className="list" style={{ marginTop: '1rem' }}>
|
||||
{employees === null && <div className="empty-line">> načítám…</div>}
|
||||
{employees?.length === 0 && <div className="empty-line">· zatím žádní zaměstnanci</div>}
|
||||
{employees?.map((emp) => (
|
||||
<div className="row" key={emp.id}>
|
||||
<div className="row-main">
|
||||
<div className="row-title">{emp.name ?? emp.email}</div>
|
||||
<div className="row-meta">
|
||||
{emp.email} · od {formatDate(emp.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="row-action">
|
||||
{emp.active ? (
|
||||
<button className="btn" disabled={busy} onClick={() => removeEmployee(emp.id)}>
|
||||
Odebrat
|
||||
</button>
|
||||
) : (
|
||||
<span className="badge badge-dashed" title="Historie docházky zůstává zachována">
|
||||
NEAKTIVNÍ
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
frontend/src/components/MonthNav.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { formatMonthLabel } from '../lib/month';
|
||||
|
||||
interface Props {
|
||||
month: string;
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
}
|
||||
|
||||
export function MonthNav({ month, onPrev, onNext }: Props) {
|
||||
return (
|
||||
<div className="month-nav">
|
||||
<button className="btn" onClick={onPrev} aria-label="Předchozí měsíc">
|
||||
<
|
||||
</button>
|
||||
<div className="month-nav-label">{formatMonthLabel(month)}</div>
|
||||
<button className="btn" onClick={onNext} aria-label="Následující měsíc">
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
frontend/src/components/SessionList.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { Session } from '../api/types';
|
||||
import { formatDate, formatDuration, formatTime } from '../lib/format';
|
||||
|
||||
export function SessionList({ sessions }: { sessions: Session[] }) {
|
||||
if (sessions.length === 0) {
|
||||
return <div className="empty-line">· žádné směny v tomto období</div>;
|
||||
}
|
||||
|
||||
const sorted = [...sessions].sort((a, b) => b.clockIn.localeCompare(a.clockIn));
|
||||
|
||||
return (
|
||||
<div className="list">
|
||||
{sorted.map((s) => (
|
||||
<div className="row" key={s.clockIn}>
|
||||
<div className="row-main">
|
||||
<div className="row-title">
|
||||
{formatDate(s.clockIn)} · {formatTime(s.clockIn)}
|
||||
{' – '}
|
||||
{s.clockOut ? formatTime(s.clockOut) : 'probíhá'}
|
||||
</div>
|
||||
<div className="row-meta">
|
||||
{s.breaks.length > 0
|
||||
? `${s.breaks.length}× pauza · ${formatDuration(s.breakMs)}`
|
||||
: 'bez pauzy'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="row-action">
|
||||
{s.open ? (
|
||||
<span className="badge badge-solid">{formatDuration(s.workedMs)}</span>
|
||||
) : (
|
||||
<span className="badge">{formatDuration(s.workedMs)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
frontend/src/components/StatsSummary.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { formatDuration } from '../lib/format';
|
||||
|
||||
interface Props {
|
||||
totalWorkedMs: number;
|
||||
totalBreakMs: number;
|
||||
shiftCount: number;
|
||||
}
|
||||
|
||||
export function StatsSummary({ totalWorkedMs, totalBreakMs, shiftCount }: Props) {
|
||||
return (
|
||||
<div className="stat-grid">
|
||||
<div className="stat-tile">
|
||||
<div className="stat-value">{formatDuration(totalWorkedMs)}</div>
|
||||
<div className="stat-label">Odpracováno</div>
|
||||
</div>
|
||||
<div className="stat-tile">
|
||||
<div className="stat-value">{formatDuration(totalBreakMs)}</div>
|
||||
<div className="stat-label">Pauzy</div>
|
||||
</div>
|
||||
<div className="stat-tile">
|
||||
<div className="stat-value">{shiftCount}</div>
|
||||
<div className="stat-label">Směny</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
frontend/src/components/StatusBadge.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { LiveStatus } from '../api/types';
|
||||
|
||||
const LABELS: Record<LiveStatus, string> = {
|
||||
clocked_out: 'ODHLÁŠEN',
|
||||
working: 'PRACUJE',
|
||||
on_break: 'PAUZA',
|
||||
};
|
||||
|
||||
export function StatusBadge({ status }: { status: LiveStatus }) {
|
||||
const label = LABELS[status];
|
||||
if (status === 'working') return <span className="badge badge-solid">{label}</span>;
|
||||
if (status === 'on_break') return <span className="badge badge-dashed">{label}</span>;
|
||||
return <span className="badge">{label}</span>;
|
||||
}
|
||||
14
frontend/src/lib/dailyAggregate.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { Session } from '../api/types';
|
||||
import { daysInMonth } from './month';
|
||||
|
||||
/** Worked ms per day of `month` ("YYYY-MM"), index 0 = day 1. A shift is credited to its clock-in day. */
|
||||
export function aggregateDailyWorkedMs(sessions: Session[], month: string): number[] {
|
||||
const days = new Array(daysInMonth(month)).fill(0) as number[];
|
||||
for (const session of sessions) {
|
||||
const day = new Date(session.clockIn).getDate();
|
||||
if (day >= 1 && day <= days.length) {
|
||||
days[day - 1] += session.workedMs;
|
||||
}
|
||||
}
|
||||
return days;
|
||||
}
|
||||
22
frontend/src/lib/format.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export function formatDuration(ms: number): string {
|
||||
const totalMinutes = Math.round(ms / 60000);
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
return `${hours}h ${String(minutes).padStart(2, '0')}m`;
|
||||
}
|
||||
|
||||
export function formatTime(iso: string): string {
|
||||
return new Date(iso).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
export function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString(undefined, {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
export function formatDateTime(iso: string): string {
|
||||
return `${formatDate(iso)} ${formatTime(iso)}`;
|
||||
}
|
||||
39
frontend/src/lib/liveSession.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { Session, StatsSummary } from '../api/types';
|
||||
|
||||
/**
|
||||
* The API computes worked/break durations at fetch time, so an open (still
|
||||
* running) session freezes until the next request. This recomputes an open
|
||||
* session's durations against a live clock so the UI can tick without polling.
|
||||
*/
|
||||
function recomputeOpenSession(session: Session, now: Date): Session {
|
||||
if (!session.open) return session;
|
||||
|
||||
const start = new Date(session.clockIn).getTime();
|
||||
const breakMs = session.breaks.reduce((sum, b) => {
|
||||
const end = b.end ? new Date(b.end).getTime() : now.getTime();
|
||||
return sum + Math.max(0, end - new Date(b.start).getTime());
|
||||
}, 0);
|
||||
const workedMs = Math.max(0, now.getTime() - start - breakMs);
|
||||
|
||||
return { ...session, breakMs, workedMs };
|
||||
}
|
||||
|
||||
export function withLiveTime(stats: StatsSummary, now: Date): StatsSummary {
|
||||
let deltaWorked = 0;
|
||||
let deltaBreak = 0;
|
||||
|
||||
const sessions = stats.sessions.map((s) => {
|
||||
if (!s.open) return s;
|
||||
const live = recomputeOpenSession(s, now);
|
||||
deltaWorked += live.workedMs - s.workedMs;
|
||||
deltaBreak += live.breakMs - s.breakMs;
|
||||
return live;
|
||||
});
|
||||
|
||||
return {
|
||||
...stats,
|
||||
sessions,
|
||||
totalWorkedMs: stats.totalWorkedMs + deltaWorked,
|
||||
totalBreakMs: stats.totalBreakMs + deltaBreak,
|
||||
};
|
||||
}
|
||||
23
frontend/src/lib/month.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export function currentMonthKey(): string {
|
||||
const now = new Date();
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function shiftMonthKey(month: string, delta: number): string {
|
||||
const [year, monthIndex] = month.split('-').map(Number);
|
||||
const d = new Date(year, monthIndex - 1 + delta, 1);
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function formatMonthLabel(month: string): string {
|
||||
const [year, monthIndex] = month.split('-').map(Number);
|
||||
return new Date(year, monthIndex - 1, 1).toLocaleDateString('cs-CZ', {
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
export function daysInMonth(month: string): number {
|
||||
const [year, monthIndex] = month.split('-').map(Number);
|
||||
return new Date(year, monthIndex, 0).getDate();
|
||||
}
|
||||
10
frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './styles/tui.css';
|
||||
import App from './App.tsx';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
40
frontend/src/pages/AdminApp.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { EmployeeManager } from '../components/EmployeeManager';
|
||||
import { AdminStats } from '../components/AdminStats';
|
||||
|
||||
type Tab = 'employees' | 'stats';
|
||||
|
||||
export function AdminApp() {
|
||||
const [tab, setTab] = useState<Tab>('employees');
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return;
|
||||
if (e.key === '1') setTab('employees');
|
||||
if (e.key === '2') setTab('stats');
|
||||
}
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="tabs">
|
||||
<button
|
||||
className={`tab ${tab === 'employees' ? 'active' : ''}`}
|
||||
onClick={() => setTab('employees')}
|
||||
>
|
||||
[1] Zaměstnanci
|
||||
</button>
|
||||
<button className={`tab ${tab === 'stats' ? 'active' : ''}`} onClick={() => setTab('stats')}>
|
||||
[2] Statistiky
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '1rem' }}>
|
||||
{tab === 'employees' ? <EmployeeManager /> : <AdminStats />}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
61
frontend/src/pages/EmployeeApp.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useEffect } from 'react';
|
||||
import useAttendanceStore from '../store/attendanceStore';
|
||||
import { ClockControls } from '../components/ClockControls';
|
||||
import { StatusBadge } from '../components/StatusBadge';
|
||||
import { StatsSummary } from '../components/StatsSummary';
|
||||
import { SessionList } from '../components/SessionList';
|
||||
|
||||
export function EmployeeApp() {
|
||||
const status = useAttendanceStore((s) => s.status);
|
||||
const busy = useAttendanceStore((s) => s.busy);
|
||||
const error = useAttendanceStore((s) => s.error);
|
||||
const live = useAttendanceStore((s) => s.live);
|
||||
const load = useAttendanceStore((s) => s.load);
|
||||
const recordEvent = useAttendanceStore((s) => s.recordEvent);
|
||||
const startPolling = useAttendanceStore((s) => s.startPolling);
|
||||
const stopPolling = useAttendanceStore((s) => s.stopPolling);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === null || status === 'clocked_out') {
|
||||
stopPolling();
|
||||
return;
|
||||
}
|
||||
startPolling();
|
||||
return () => stopPolling();
|
||||
}, [status, startPolling, stopPolling]);
|
||||
|
||||
if (!status || !live) {
|
||||
return <div className="empty-line">> načítám…</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="panel">
|
||||
<p className="panel-title">Docházka</p>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '1rem' }}>
|
||||
<StatusBadge status={status} />
|
||||
</div>
|
||||
<ClockControls status={status} busy={busy} onAction={recordEvent} />
|
||||
{error && <div className="login-error">{error}</div>}
|
||||
</div>
|
||||
|
||||
<div className="panel">
|
||||
<p className="panel-title">Statistiky · tento měsíc</p>
|
||||
<StatsSummary
|
||||
totalWorkedMs={live.totalWorkedMs}
|
||||
totalBreakMs={live.totalBreakMs}
|
||||
shiftCount={live.shiftCount}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="panel">
|
||||
<p className="panel-title">Směny</p>
|
||||
<SessionList sessions={live.sessions} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
18
frontend/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import useAuthStore from '../store/authStore';
|
||||
import { GoogleButton } from '../auth/GoogleButton';
|
||||
|
||||
export function LoginPage() {
|
||||
const loginError = useAuthStore((s) => s.loginError);
|
||||
|
||||
return (
|
||||
<div className="login-shell">
|
||||
<div className="login-card">
|
||||
<img className="login-logo" src="/icon-192.png" alt="EatMe" />
|
||||
<p className="login-title">EatMe · Docházka</p>
|
||||
<p className="login-sub">Přihlas se firemním Google účtem</p>
|
||||
<GoogleButton />
|
||||
{loginError && <div className="login-error">{loginError}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
105
frontend/src/store/adminStore.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { create } from 'zustand';
|
||||
import { api, ApiError } from '../api/client';
|
||||
import type { AdminStatsSummary, Employee, StatsSummary } from '../api/types';
|
||||
import { currentMonthKey, shiftMonthKey } from '../lib/month';
|
||||
|
||||
interface AdminState {
|
||||
employees: Employee[] | null;
|
||||
employeesError: string | null;
|
||||
employeesBusy: boolean;
|
||||
|
||||
stats: AdminStatsSummary | null;
|
||||
|
||||
month: string; // "YYYY-MM", the period shown for the selected employee's detail
|
||||
selectedEmployeeId: number | null;
|
||||
employeeDetail: StatsSummary | null;
|
||||
detailLoading: boolean;
|
||||
|
||||
loadEmployees: () => Promise<void>;
|
||||
addEmployee: (email: string) => Promise<void>;
|
||||
removeEmployee: (id: number) => Promise<void>;
|
||||
|
||||
loadStats: () => Promise<void>;
|
||||
selectEmployee: (id: number) => Promise<void>;
|
||||
setMonth: (month: string) => Promise<void>;
|
||||
prevMonth: () => Promise<void>;
|
||||
nextMonth: () => Promise<void>;
|
||||
}
|
||||
|
||||
const useAdminStore = create<AdminState>((set, get) => ({
|
||||
employees: null,
|
||||
employeesError: null,
|
||||
employeesBusy: false,
|
||||
|
||||
stats: null,
|
||||
|
||||
month: currentMonthKey(),
|
||||
selectedEmployeeId: null,
|
||||
employeeDetail: null,
|
||||
detailLoading: false,
|
||||
|
||||
loadEmployees: async () => {
|
||||
try {
|
||||
const res = await api.get<{ employees: Employee[] }>('/admin/employees');
|
||||
set({ employees: res.employees, employeesError: null });
|
||||
} catch {
|
||||
set({ employeesError: 'Nepodařilo se načíst zaměstnance' });
|
||||
}
|
||||
},
|
||||
|
||||
addEmployee: async (email) => {
|
||||
set({ employeesBusy: true, employeesError: null });
|
||||
try {
|
||||
await api.post('/admin/employees', { email });
|
||||
await get().loadEmployees();
|
||||
} catch (err) {
|
||||
set({ employeesError: err instanceof ApiError ? err.message : 'Přidání se nezdařilo' });
|
||||
} finally {
|
||||
set({ employeesBusy: false });
|
||||
}
|
||||
},
|
||||
|
||||
removeEmployee: async (id) => {
|
||||
set({ employeesBusy: true, employeesError: null });
|
||||
try {
|
||||
await api.delete(`/admin/employees/${id}`);
|
||||
await get().loadEmployees();
|
||||
} catch (err) {
|
||||
set({ employeesError: err instanceof ApiError ? err.message : 'Odebrání se nezdařilo' });
|
||||
} finally {
|
||||
set({ employeesBusy: false });
|
||||
}
|
||||
},
|
||||
|
||||
loadStats: async () => {
|
||||
const res = await api.get<AdminStatsSummary>(`/admin/stats?month=${get().month}`);
|
||||
set({ stats: res });
|
||||
},
|
||||
|
||||
selectEmployee: async (id) => {
|
||||
set({ selectedEmployeeId: id, detailLoading: true });
|
||||
try {
|
||||
const res = await api.get<StatsSummary>(`/admin/stats/${id}?month=${get().month}`);
|
||||
set({ employeeDetail: res });
|
||||
} finally {
|
||||
set({ detailLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
setMonth: async (month) => {
|
||||
set({ month });
|
||||
await get().loadStats();
|
||||
const id = get().selectedEmployeeId;
|
||||
if (id != null) await get().selectEmployee(id);
|
||||
},
|
||||
|
||||
prevMonth: async () => {
|
||||
await get().setMonth(shiftMonthKey(get().month, -1));
|
||||
},
|
||||
|
||||
nextMonth: async () => {
|
||||
await get().setMonth(shiftMonthKey(get().month, 1));
|
||||
},
|
||||
}));
|
||||
|
||||
export default useAdminStore;
|
||||
82
frontend/src/store/attendanceStore.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { create } from 'zustand';
|
||||
import { api, ApiError } from '../api/client';
|
||||
import type { EventType, LiveStatus, StatsSummary } from '../api/types';
|
||||
import { withLiveTime } from '../lib/liveSession';
|
||||
|
||||
const POLL_MS = 60_000;
|
||||
const TICK_MS = 1_000;
|
||||
|
||||
interface AttendanceState {
|
||||
status: LiveStatus | null;
|
||||
stats: StatsSummary | null;
|
||||
/** `stats` recomputed against a live clock, cached so it only changes reference on an actual tick. */
|
||||
live: StatsSummary | null;
|
||||
busy: boolean;
|
||||
error: string | null;
|
||||
pollTimer: ReturnType<typeof setInterval> | null;
|
||||
tickTimer: ReturnType<typeof setInterval> | null;
|
||||
|
||||
load: () => Promise<void>;
|
||||
recordEvent: (type: EventType) => Promise<void>;
|
||||
startPolling: () => void;
|
||||
stopPolling: () => void;
|
||||
}
|
||||
|
||||
const useAttendanceStore = create<AttendanceState>((set, get) => ({
|
||||
status: null,
|
||||
stats: null,
|
||||
live: null,
|
||||
busy: false,
|
||||
error: null,
|
||||
pollTimer: null,
|
||||
tickTimer: null,
|
||||
|
||||
load: async () => {
|
||||
try {
|
||||
const [stateRes, statsRes] = await Promise.all([
|
||||
api.get<{ status: LiveStatus }>('/attendance/state'),
|
||||
api.get<StatsSummary>('/attendance/me'),
|
||||
]);
|
||||
const now = new Date();
|
||||
set({ status: stateRes.status, stats: statsRes, live: withLiveTime(statsRes, now), error: null });
|
||||
} catch {
|
||||
set({ error: 'Nepodařilo se načíst data' });
|
||||
}
|
||||
},
|
||||
|
||||
recordEvent: async (type) => {
|
||||
set({ busy: true, error: null });
|
||||
try {
|
||||
await api.post('/attendance/event', { type });
|
||||
await get().load();
|
||||
} catch (err) {
|
||||
set({ error: err instanceof ApiError ? err.message : 'Akce se nezdařila' });
|
||||
} finally {
|
||||
set({ busy: false });
|
||||
}
|
||||
},
|
||||
|
||||
// Polls the server every minute (so a shift that keeps running stays in sync)
|
||||
// and ticks a local clock every second so the on-screen duration counts up
|
||||
// smoothly between polls instead of freezing until the next reload.
|
||||
startPolling: () => {
|
||||
if (get().pollTimer || get().tickTimer) return;
|
||||
const poll = setInterval(() => {
|
||||
get().load();
|
||||
}, POLL_MS);
|
||||
const tick = setInterval(() => {
|
||||
const { stats } = get();
|
||||
if (stats) set({ live: withLiveTime(stats, new Date()) });
|
||||
}, TICK_MS);
|
||||
set({ pollTimer: poll, tickTimer: tick });
|
||||
},
|
||||
|
||||
stopPolling: () => {
|
||||
const { pollTimer, tickTimer } = get();
|
||||
if (pollTimer) clearInterval(pollTimer);
|
||||
if (tickTimer) clearInterval(tickTimer);
|
||||
set({ pollTimer: null, tickTimer: null });
|
||||
},
|
||||
}));
|
||||
|
||||
export default useAttendanceStore;
|
||||
44
frontend/src/store/authStore.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { create } from 'zustand';
|
||||
import { api, ApiError } from '../api/client';
|
||||
import type { SessionUser } from '../api/types';
|
||||
|
||||
interface AuthState {
|
||||
user: SessionUser | null;
|
||||
loading: boolean;
|
||||
loginError: string | null;
|
||||
init: () => Promise<void>;
|
||||
loginWithGoogle: (credential: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
|
||||
const useAuthStore = create<AuthState>((set) => ({
|
||||
user: null,
|
||||
loading: true,
|
||||
loginError: null,
|
||||
|
||||
init: async () => {
|
||||
try {
|
||||
const res = await api.get<{ user: SessionUser }>('/auth/me');
|
||||
set({ user: res.user, loading: false });
|
||||
} catch {
|
||||
set({ user: null, loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
loginWithGoogle: async (credential) => {
|
||||
set({ loginError: null });
|
||||
try {
|
||||
const res = await api.post<{ user: SessionUser }>('/auth/google', { credential });
|
||||
set({ user: res.user });
|
||||
} catch (err) {
|
||||
set({ loginError: err instanceof ApiError ? err.message : 'Přihlášení se nezdařilo' });
|
||||
}
|
||||
},
|
||||
|
||||
logout: async () => {
|
||||
await api.post('/auth/logout');
|
||||
set({ user: null });
|
||||
},
|
||||
}));
|
||||
|
||||
export default useAuthStore;
|
||||
520
frontend/src/styles/tui.css
Normal file
@@ -0,0 +1,520 @@
|
||||
:root {
|
||||
--bg: #f0f0f0;
|
||||
--fg: #000000;
|
||||
--muted: #666666;
|
||||
--border: #000000;
|
||||
--hover: #e0e0e0;
|
||||
--empty: #d8d8d8;
|
||||
--panel: #fafafa;
|
||||
--font: 'IBM Plex Mono', 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family: var(--font);
|
||||
font-size: 13px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
body.app {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
/* ---------- shell ---------- */
|
||||
|
||||
.app-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chrome {
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.chrome-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.6rem 1rem;
|
||||
max-width: 72rem;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.brand-sub {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.chrome-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.scroll-inner {
|
||||
max-width: 72rem;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* ---------- tabs ---------- */
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
border: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
padding: 0.5rem 0.75rem;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.04em;
|
||||
background: var(--bg);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.tab:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
background: var(--hover);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background: var(--fg);
|
||||
color: var(--bg);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* ---------- buttons ---------- */
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
padding: 0.5rem 1rem;
|
||||
text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.03em;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn:hover:not(:disabled) {
|
||||
background: var(--fg);
|
||||
color: var(--bg);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
color: var(--muted);
|
||||
border-color: var(--muted);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-block {
|
||||
width: 100%;
|
||||
padding: 0.9rem 1rem;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
border: none;
|
||||
background: transparent;
|
||||
text-decoration: underline;
|
||||
text-transform: none;
|
||||
font-weight: 400;
|
||||
padding: 0.2rem 0.4rem;
|
||||
}
|
||||
|
||||
.btn-ghost:hover:not(:disabled) {
|
||||
background: var(--hover);
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
/* ---------- panels / bordered ---------- */
|
||||
|
||||
.bordered {
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.panel {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.panel + .panel {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
text-transform: uppercase;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--muted);
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
/* ---------- field row (bordered input + button) ---------- */
|
||||
|
||||
.field-row {
|
||||
display: flex;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.field-row input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: var(--panel);
|
||||
padding: 0.6rem 0.75rem;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
color: var(--fg);
|
||||
outline: none;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.field-row input::placeholder {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.field-row .btn {
|
||||
border: none;
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* ---------- dense list rows ---------- */
|
||||
|
||||
.list {
|
||||
border: 1px solid var(--border);
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
background: var(--hover);
|
||||
}
|
||||
|
||||
.row-main {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.row-title {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.row-meta {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
.row-action {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* ---------- badges ---------- */
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
padding: 0.15rem 0.4rem;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.badge-solid {
|
||||
background: var(--fg);
|
||||
color: var(--bg);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.badge-dashed {
|
||||
border-style: dashed;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.badge-double {
|
||||
border-width: 3px;
|
||||
border-style: double;
|
||||
}
|
||||
|
||||
/* ---------- stat tiles ---------- */
|
||||
|
||||
.stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.stat-tile {
|
||||
padding: 0.75rem;
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.stat-tile:first-child {
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
/* ---------- login ---------- */
|
||||
|
||||
.login-shell {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 22rem;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
padding: 2rem 1.75rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-logo {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
margin: 0 auto 1rem;
|
||||
display: block;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.login-title {
|
||||
text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
margin: 0 0 0.25rem;
|
||||
}
|
||||
|
||||
.login-sub {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
margin: 0 0 1.5rem;
|
||||
}
|
||||
|
||||
.google-btn-slot {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.login-error {
|
||||
margin-top: 1rem;
|
||||
font-size: 11px;
|
||||
color: var(--fg);
|
||||
border: 1px dashed var(--border);
|
||||
padding: 0.5rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* ---------- month nav ---------- */
|
||||
|
||||
.month-nav {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
border: 1px solid var(--border);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.month-nav .btn {
|
||||
border: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.month-nav .btn:first-child {
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.month-nav .btn:last-child {
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.month-nav-label {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-transform: uppercase;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
/* ---------- bar chart ---------- */
|
||||
|
||||
.bar-chart-wrap {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
padding: 0.75rem 0.5rem 0.5rem;
|
||||
}
|
||||
|
||||
.bar-chart-yaxis {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
height: 140px;
|
||||
padding-right: 0.5rem;
|
||||
border-right: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
font-size: 9px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.bar-chart-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bar-chart {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
height: 140px;
|
||||
background-image: repeating-linear-gradient(
|
||||
to top,
|
||||
var(--empty) 0,
|
||||
var(--empty) 1px,
|
||||
transparent 1px,
|
||||
transparent 25%
|
||||
);
|
||||
}
|
||||
|
||||
.bar-col {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
align-items: stretch;
|
||||
min-width: 2px;
|
||||
}
|
||||
|
||||
.bar {
|
||||
background: var(--fg);
|
||||
min-height: 1px;
|
||||
}
|
||||
|
||||
.bar-col:hover .bar {
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.bar-chart-labels {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.bar-col-label {
|
||||
flex: 1;
|
||||
min-width: 2px;
|
||||
color: var(--muted);
|
||||
font-size: 9px;
|
||||
text-align: center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* ---------- empty / loading ---------- */
|
||||
|
||||
.empty-line {
|
||||
color: var(--muted);
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
/* ---------- focus ---------- */
|
||||
|
||||
:focus-visible {
|
||||
outline: 1px solid var(--fg);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
37
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_GOOGLE_CLIENT_ID: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
|
||||
interface GoogleCredentialResponse {
|
||||
credential: string;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
google?: {
|
||||
accounts: {
|
||||
id: {
|
||||
initialize(config: {
|
||||
client_id: string;
|
||||
callback: (response: GoogleCredentialResponse) => void;
|
||||
}): void;
|
||||
renderButton(
|
||||
parent: HTMLElement,
|
||||
options: {
|
||||
theme?: 'outline' | 'filled_black' | 'filled_blue';
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
text?: 'signin_with' | 'signup_with' | 'continue_with';
|
||||
shape?: 'rectangular' | 'pill' | 'circle' | 'square';
|
||||
width?: number;
|
||||
}
|
||||
): void;
|
||||
prompt(): void;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
26
frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"module": "esnext",
|
||||
"types": ["vite/client"],
|
||||
"allowArbitraryExtensions": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
23
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"module": "nodenext",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
38
frontend/vite.config.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
includeAssets: ['favicon.ico', 'apple-touch-icon.png'],
|
||||
manifest: {
|
||||
name: 'EatMe — Docházka',
|
||||
short_name: 'EatMe',
|
||||
description: 'Evidence docházky zaměstnanců bistra EatMe',
|
||||
theme_color: '#000000',
|
||||
background_color: '#f0f0f0',
|
||||
display: 'standalone',
|
||||
start_url: '/',
|
||||
icons: [
|
||||
{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' },
|
||||
{ src: '/icon-512.png', sizes: '512x512', type: 'image/png' },
|
||||
{ src: '/icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
server: {
|
||||
port: 5174,
|
||||
strictPort: true,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:4000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||