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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||