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>
This commit is contained in:
Michal Pemcak
2026-08-12 11:57:55 +02:00
commit fffcb73ea4
90 changed files with 3411 additions and 0 deletions

2
frontend/.env.example Normal file
View 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
View 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
View 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
View 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
View 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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

50
frontend/src/App.tsx Normal file
View 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">&gt; 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} &middot; {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;

View 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
View 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[];
}

View 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" />;
}

View 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">&gt; načítám</div>;
return (
<>
<div className="panel">
<p className="panel-title">Souhrn &middot; {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 &middot; 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 &middot; 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 &middot;{' '}
{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">&gt; načítám</div>}
{detail && (
<>
<BarChart values={aggregateDailyWorkedMs(detail.sessions, month)} month={month} />
<div style={{ marginTop: '1rem' }}>
<SessionList sessions={detail.sessions} />
</div>
</>
)}
</div>
)}
</>
);
}

View 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>
);
}

View 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>
);
}

View 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 &middot; přístup přes Google účet</p>
<form className="field-row" onSubmit={handleAdd}>
<input
type="email"
placeholder="&gt; 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">&gt; 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} &middot; 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>
);
}

View 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">
&lt;
</button>
<div className="month-nav-label">{formatMonthLabel(month)}</div>
<button className="btn" onClick={onNext} aria-label="Následující měsíc">
&gt;
</button>
</div>
);
}

View 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)} &middot; {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>
);
}

View 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>
);
}

View 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>;
}

View 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;
}

View 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)}`;
}

View 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
View 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
View 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>
);

View 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>
</>
);
}

View 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">&gt; 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 &middot; 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>
</>
);
}

View 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 &middot; 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>
);
}

View 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;

View 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;

View 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
View 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
View 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;
};
};
};
}

View 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
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

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