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:
2
frontend/.env.example
Normal file
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
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
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
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
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
BIN
frontend/public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
BIN
frontend/public/favicon.ico
Normal file
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
BIN
frontend/public/icon-192.png
Normal file
BIN
frontend/public/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
BIN
frontend/public/icon-512.png
Normal file
BIN
frontend/public/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 260 KiB |
BIN
frontend/public/icon-maskable-512.png
Normal file
BIN
frontend/public/icon-maskable-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 193 KiB |
50
frontend/src/App.tsx
Normal file
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user