Add shift planning, month closure + payroll, and a dark modern redesign

Ports two modules from the friend's Google Apps Script build (kept as
reference in gscript/) onto the TypeScript stack, rewritten cleanly against
this app's own data model rather than copied 1:1:

- Shift planning: weekly template (Sun-Thu evening, Fri/Sat two slots),
  lazy idempotent generation per period (no cron needed), employee
  signup/cancel with collision + capacity checks, admin calendar view with
  slot editing and manual assignment.
- Month closure + payroll: employee confirms the month (blocked while a
  shift is still open), admin locks and finalizes pay (base hours * rate +
  tips/bonus/other), reopen to undo a premature lock, mark paid. Pay rates
  are versioned by date, defaulting the first-ever rate to apply
  retroactively to the employee's whole history.
- A shift left open more than 12h (forgotten clock-out) is auto-closed at
  clock_in + 12h, checked lazily on read instead of a background job.
- Full dark, sharp-edged modern restyle (theme.css replaces tui.css) with
  an amber accent, keeping every existing class name so no component
  logic needed to change.

Backend test coverage (jest) for all three workflows: shift planning,
closure/payroll, and the forgotten-clock-out auto-close.
This commit is contained in:
Michal Pemcak
2026-08-16 18:27:50 +02:00
parent fffcb73ea4
commit f917ed06a8
64 changed files with 46554 additions and 553 deletions

View File

@@ -5,7 +5,7 @@
<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="theme-color" content="#0d0f13" />
<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>

View File

@@ -34,5 +34,7 @@ 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 }),
patch: <T>(path: string, data?: unknown) =>
request<T>(path, { method: 'PATCH', body: data ? JSON.stringify(data) : undefined }),
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
};

View File

@@ -53,3 +53,90 @@ export interface AdminStatsSummary {
totals: { totalWorkedMs: number; totalBreakMs: number; shiftCount: number };
employees: EmployeeStat[];
}
export type ShiftSlotStatus = 'open' | 'full' | 'closed';
export interface AvailableShiftSlot {
slot_id: number;
date: string;
start_time: string;
end_time: string;
capacity: number;
occupied: number;
free_places: number;
note: string;
}
export interface MyShiftSlot {
signup_id: number;
slot_id: number;
date: string;
start_time: string;
end_time: string;
note: string;
}
export interface AdminShiftSlot {
id: number;
date: string;
start_time: string;
end_time: string;
capacity: number;
status: ShiftSlotStatus;
note: string;
generated: boolean;
occupied: number;
employees: { employee_id: number; name: string }[];
}
export interface EmployeeWithRate extends Employee {
hourly_rate: number;
}
export type ClosureStatus = 'waiting_employee' | 'confirmed' | 'locked';
export interface MonthClosure {
id: number;
employee_id: number;
period: string;
status: ClosureStatus;
employee_confirmed_at: string | null;
locked_at: string | null;
}
export interface ClosureSummary {
worked_minutes: number;
shift_count: number;
earned_estimate: number;
}
export type PayrollStatus = 'draft' | 'ready' | 'paid';
export interface Payroll {
id: number;
employee_id: number;
period: string;
worked_minutes: number;
base_amount: number;
tips_amount: number;
bonus_amount: number;
other_amount: number;
final_amount: number;
status: PayrollStatus;
payment_date: string | null;
updated_at: string;
}
export interface PayrollOverviewRow {
employee_id: number;
employee_name: string;
closure_status: ClosureStatus;
worked_minutes: number;
base_amount: number;
tips_amount: number;
bonus_amount: number;
other_amount: number;
final_amount: number;
payroll_status: PayrollStatus | null;
payment_date: string | null;
}

View File

@@ -0,0 +1,66 @@
import { useEffect } from 'react';
import useClosureStore from '../store/closureStore';
import { MonthNav } from './MonthNav';
import { formatDuration } from '../lib/format';
const STATUS_LABEL: Record<string, string> = {
waiting_employee: 'čeká na tvé potvrzení',
confirmed: 'potvrzeno, čeká na zpracování',
locked: 'uzamčeno',
};
export function ClosureCard() {
const period = useClosureStore((s) => s.period);
const closure = useClosureStore((s) => s.closure);
const summary = useClosureStore((s) => s.summary);
const busy = useClosureStore((s) => s.busy);
const error = useClosureStore((s) => s.error);
const load = useClosureStore((s) => s.load);
const prevPeriod = useClosureStore((s) => s.prevPeriod);
const nextPeriod = useClosureStore((s) => s.nextPeriod);
const confirm = useClosureStore((s) => s.confirm);
useEffect(() => {
load();
}, [load]);
return (
<div className="panel">
<p className="panel-title">Uzávěrka měsíce</p>
<MonthNav month={period} onPrev={prevPeriod} onNext={nextPeriod} />
{error && <div className="login-error">{error}</div>}
{!closure || !summary ? (
<div className="empty-line">&gt; načítám</div>
) : (
<>
<div className="stat-grid" style={{ marginTop: '1rem' }}>
<div className="stat-tile">
<div className="stat-value">{formatDuration(summary.worked_minutes * 60_000)}</div>
<div className="stat-label">odpracováno</div>
</div>
<div className="stat-tile">
<div className="stat-value">{summary.shift_count}</div>
<div className="stat-label">směn</div>
</div>
<div className="stat-tile">
<div className="stat-value">{summary.earned_estimate} </div>
<div className="stat-label">odhad výdělku</div>
</div>
</div>
<div style={{ marginTop: '1rem', display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<span className={`badge ${closure.status === 'waiting_employee' ? 'badge-dashed' : 'badge-solid'}`}>
{STATUS_LABEL[closure.status]}
</span>
{closure.status === 'waiting_employee' && (
<button className="btn" disabled={busy} onClick={confirm}>
Potvrdit docházku
</button>
)}
</div>
</>
)}
</div>
);
}

View File

@@ -9,6 +9,7 @@ export function EmployeeManager() {
const loadEmployees = useAdminStore((s) => s.loadEmployees);
const addEmployee = useAdminStore((s) => s.addEmployee);
const removeEmployee = useAdminStore((s) => s.removeEmployee);
const setRate = useAdminStore((s) => s.setRate);
const [email, setEmail] = useState('');
@@ -46,27 +47,65 @@ export function EmployeeManager() {
{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>
<EmployeeRow key={emp.id} employee={emp} busy={busy} onRemove={removeEmployee} onSetRate={setRate} />
))}
</div>
</div>
);
}
function EmployeeRow({
employee,
busy,
onRemove,
onSetRate,
}: {
employee: { id: number; email: string; name: string | null; active: 0 | 1; created_at: string; hourly_rate: number };
busy: boolean;
onRemove: (id: number) => void;
onSetRate: (id: number, hourlyRate: number) => void;
}) {
const [rate, setRate] = useState(String(employee.hourly_rate));
const dirty = Number(rate) !== employee.hourly_rate;
return (
<div className="row">
<div className="row-main">
<div className="row-title">{employee.name ?? employee.email}</div>
<div className="row-meta">
{employee.email} &middot; od {formatDate(employee.created_at)}
</div>
</div>
<div className="row-action">
<label style={{ display: 'flex', alignItems: 'center', gap: '0.35rem' }}>
<span className="row-meta" style={{ margin: 0 }}>
/h
</span>
<input
type="number"
min={0}
value={rate}
onChange={(e) => setRate(e.target.value)}
disabled={busy}
aria-label="Hodinová sazba v Kč"
style={{ width: '4rem' }}
/>
</label>
{dirty && (
<button className="btn" disabled={busy} onClick={() => onSetRate(employee.id, Number(rate))}>
Uložit sazbu
</button>
)}
{employee.active ? (
<button className="btn" disabled={busy} onClick={() => onRemove(employee.id)}>
Odebrat
</button>
) : (
<span className="badge badge-dashed" title="Historie docházky zůstává zachována">
NEAKTIVNÍ
</span>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,61 @@
import type { ReactNode } from 'react';
const WEEKDAY_LABELS = ['Po', 'Út', 'St', 'Čt', 'Pá', 'So', 'Ne'];
interface Props {
month: string; // "YYYY-MM"
renderDay: (dateStr: string, dayNumber: number) => ReactNode;
}
/** JS Date#getDay() is 0=Sun..6=Sat; the Czech week starts Monday. */
function mondayFirst(jsDay: number): number {
return (jsDay + 6) % 7;
}
export function MonthCalendar({ month, renderDay }: Props) {
const [yearStr, monthStr] = month.split('-');
const year = Number(yearStr);
const monthIndex = Number(monthStr) - 1;
const daysInMonth = new Date(year, monthIndex + 1, 0).getDate();
const leadingBlanks = mondayFirst(new Date(year, monthIndex, 1).getDay());
const totalCells = Math.ceil((leadingBlanks + daysInMonth) / 7) * 7;
const today = new Date();
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(
today.getDate()
).padStart(2, '0')}`;
const cells = Array.from({ length: totalCells }, (_, i) => {
const dayNumber = i - leadingBlanks + 1;
if (dayNumber < 1 || dayNumber > daysInMonth) return null;
return `${year}-${String(monthIndex + 1).padStart(2, '0')}-${String(dayNumber).padStart(2, '0')}`;
});
return (
<div className="month-calendar-scroll">
<div className="month-calendar">
<div className="month-calendar-head">
{WEEKDAY_LABELS.map((label) => (
<div key={label}>{label}</div>
))}
</div>
<div className="month-calendar-body">
{cells.map((dateStr, i) => (
<div
key={i}
className={`month-calendar-cell ${dateStr ? '' : 'is-outside'} ${dateStr === todayStr ? 'is-today' : ''}`}
>
{dateStr && (
<>
<div className="month-calendar-daynum">{Number(dateStr.slice(8, 10))}</div>
{renderDay(dateStr, Number(dateStr.slice(8, 10)))}
</>
)}
</div>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,169 @@
import { useEffect, useState } from 'react';
import useAdminPayrollStore, { type Adjustments } from '../store/adminPayrollStore';
import type { PayrollOverviewRow } from '../api/types';
import { MonthNav } from './MonthNav';
import { formatDuration } from '../lib/format';
const CLOSURE_LABEL: Record<string, string> = {
waiting_employee: 'čeká na zaměstnance',
confirmed: 'potvrzeno',
locked: 'uzamčeno',
};
const PAYROLL_LABEL: Record<string, string> = {
draft: 'rozpracováno',
ready: 'připraveno k výplatě',
paid: 'vyplaceno',
};
export function PayrollManager() {
const period = useAdminPayrollStore((s) => s.period);
const rows = useAdminPayrollStore((s) => s.rows);
const busy = useAdminPayrollStore((s) => s.busy);
const error = useAdminPayrollStore((s) => s.error);
const load = useAdminPayrollStore((s) => s.load);
const prevPeriod = useAdminPayrollStore((s) => s.prevPeriod);
const nextPeriod = useAdminPayrollStore((s) => s.nextPeriod);
const saveAdjustments = useAdminPayrollStore((s) => s.saveAdjustments);
const lock = useAdminPayrollStore((s) => s.lock);
const reopen = useAdminPayrollStore((s) => s.reopen);
const markPaid = useAdminPayrollStore((s) => s.markPaid);
useEffect(() => {
load();
}, [load]);
return (
<div className="panel">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<p className="panel-title">Mzdy</p>
<a className="btn" href={`/api/admin/payroll/export?period=${period}`} download>
Export CSV
</a>
</div>
<MonthNav month={period} onPrev={prevPeriod} onNext={nextPeriod} />
{error && <div className="login-error">{error}</div>}
<div className="list" style={{ marginTop: '1rem' }}>
{rows === null && <div className="empty-line">&gt; načítám</div>}
{rows?.length === 0 && <div className="empty-line">· žádní zaměstnanci</div>}
{rows?.map((row) => (
<PayrollRow
key={row.employee_id}
row={row}
busy={busy}
onSave={(data) => saveAdjustments(row.employee_id, data)}
onLock={() => lock(row.employee_id)}
onReopen={() => reopen(row.employee_id)}
onMarkPaid={() => markPaid(row.employee_id)}
/>
))}
</div>
</div>
);
}
function PayrollRow({
row,
busy,
onSave,
onLock,
onReopen,
onMarkPaid,
}: {
row: PayrollOverviewRow;
busy: boolean;
onSave: (data: Adjustments) => void;
onLock: () => void;
onReopen: () => void;
onMarkPaid: () => void;
}) {
const [tips, setTips] = useState(String(row.tips_amount));
const [bonus, setBonus] = useState(String(row.bonus_amount));
const [other, setOther] = useState(String(row.other_amount));
const editable = row.closure_status !== 'waiting_employee' && row.payroll_status !== 'ready' && row.payroll_status !== 'paid';
const dirty = Number(tips) !== row.tips_amount || Number(bonus) !== row.bonus_amount || Number(other) !== row.other_amount;
return (
<div className="row" style={{ flexWrap: 'wrap' }}>
<div className="row-main">
<div className="row-title">
{row.employee_name} <span className="badge badge-dashed">{CLOSURE_LABEL[row.closure_status]}</span>
{row.payroll_status && <span className="badge badge-solid">{PAYROLL_LABEL[row.payroll_status]}</span>}
</div>
<div className="row-meta">
{formatDuration(row.worked_minutes * 60_000)} &middot; základ {row.base_amount} &middot; celkem{' '}
{row.final_amount}
</div>
{editable && (
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '0.4rem', flexWrap: 'wrap', alignItems: 'center' }}>
<AmountField label="spropitné" value={tips} onChange={setTips} busy={busy} />
<AmountField label="bonus" value={bonus} onChange={setBonus} busy={busy} />
<AmountField label="ostatní" value={other} onChange={setOther} busy={busy} />
{dirty && (
<button
className="btn"
disabled={busy}
onClick={() => onSave({ tips_amount: Number(tips), bonus_amount: Number(bonus), other_amount: Number(other) })}
>
Uložit
</button>
)}
{row.closure_status === 'confirmed' && (
<button className="btn" disabled={busy} onClick={onLock}>
Zamknout a předat k výplatě
</button>
)}
</div>
)}
{row.payroll_status === 'ready' && (
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.4rem' }}>
<button className="btn" disabled={busy} onClick={onMarkPaid}>
Označit jako vyplaceno
</button>
<button className="btn-ghost" disabled={busy} onClick={onReopen}>
Odemknout a upravit
</button>
</div>
)}
{row.payroll_status === 'paid' && (
<div className="row-meta" style={{ marginTop: '0.2rem' }}>
vyplaceno {row.payment_date}
</div>
)}
</div>
</div>
);
}
function AmountField({
label,
value,
onChange,
busy,
}: {
label: string;
value: string;
onChange: (value: string) => void;
busy: boolean;
}) {
return (
<label style={{ display: 'flex', alignItems: 'center', gap: '0.35rem' }}>
<span className="row-meta" style={{ margin: 0 }}>
{label}
</span>
<input
type="number"
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={busy}
aria-label={label}
style={{ width: '5rem' }}
/>
</label>
);
}

View File

@@ -0,0 +1,274 @@
import { useEffect, useMemo, useState } from 'react';
import useAdminShiftStore from '../store/adminShiftStore';
import useAdminStore from '../store/adminStore';
import type { AdminShiftSlot } from '../api/types';
import { MonthNav } from './MonthNav';
import { MonthCalendar } from './MonthCalendar';
import { formatDateOnly } from '../lib/format';
export function ShiftPlanManager() {
const period = useAdminShiftStore((s) => s.period);
const slots = useAdminShiftStore((s) => s.slots);
const busy = useAdminShiftStore((s) => s.busy);
const error = useAdminShiftStore((s) => s.error);
const load = useAdminShiftStore((s) => s.load);
const prevPeriod = useAdminShiftStore((s) => s.prevPeriod);
const nextPeriod = useAdminShiftStore((s) => s.nextPeriod);
const createSlot = useAdminShiftStore((s) => s.createSlot);
const updateSlot = useAdminShiftStore((s) => s.updateSlot);
const assignEmployee = useAdminShiftStore((s) => s.assignEmployee);
const removeEmployee = useAdminShiftStore((s) => s.removeEmployee);
const employees = useAdminStore((s) => s.employees);
const loadEmployees = useAdminStore((s) => s.loadEmployees);
const [selectedDate, setSelectedDate] = useState<string | null>(null);
useEffect(() => {
load();
if (!employees) loadEmployees();
}, [load, loadEmployees, employees]);
const slotsByDate = useMemo(() => {
const map = new Map<string, AdminShiftSlot[]>();
slots?.forEach((slot) => {
const list = map.get(slot.date) ?? [];
list.push(slot);
map.set(slot.date, list);
});
return map;
}, [slots]);
function renderDay(dateStr: string) {
const daySlots = slotsByDate.get(dateStr) ?? [];
return (
<button
className={`month-calendar-day-btn ${selectedDate === dateStr ? 'is-selected' : ''}`}
onClick={() => setSelectedDate(dateStr)}
>
{daySlots.map((slot) => (
<span key={slot.id} className={`calendar-chip ${slot.status}`}>
{slot.start_time}{slot.end_time} {slot.occupied}/{slot.capacity}
</span>
))}
</button>
);
}
const daySlots = selectedDate ? slotsByDate.get(selectedDate) ?? [] : [];
return (
<div className="panel">
<p className="panel-title">Plán směn</p>
<MonthNav month={period} onPrev={prevPeriod} onNext={nextPeriod} />
{error && <div className="login-error">{error}</div>}
{slots === null ? (
<div className="empty-line">&gt; načítám</div>
) : (
<MonthCalendar month={period} renderDay={renderDay} />
)}
<div style={{ marginTop: '1rem' }}>
<p className="panel-title">{selectedDate ? formatDateOnly(selectedDate) : 'Vyber den v kalendáři'}</p>
{selectedDate && (
<>
<div className="list">
{daySlots.length === 0 && <div className="empty-line">· žádné směny</div>}
{daySlots.map((slot) => (
<SlotRow
key={slot.id}
slot={slot}
busy={busy}
employees={employees ?? []}
onSave={(data) => updateSlot(slot.id, data)}
onAssign={(employeeId) => assignEmployee(slot.id, employeeId)}
onRemove={(employeeId) => removeEmployee(slot.id, employeeId)}
/>
))}
</div>
<div style={{ marginTop: '0.75rem' }}>
<NewSlotForm key={selectedDate} busy={busy} defaultDate={selectedDate} onCreate={createSlot} />
</div>
</>
)}
</div>
</div>
);
}
function NewSlotForm({
busy,
defaultDate,
onCreate,
}: {
busy: boolean;
defaultDate: string;
onCreate: (data: { date: string; start_time: string; end_time: string; capacity: number; note?: string }) => Promise<void>;
}) {
const [date, setDate] = useState(defaultDate);
const [startTime, setStartTime] = useState('');
const [endTime, setEndTime] = useState('');
const [capacity, setCapacity] = useState('1');
const [note, setNote] = useState('');
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!date || !startTime || !endTime) return;
await onCreate({ date, start_time: startTime, end_time: endTime, capacity: Number(capacity), note: note || undefined });
setStartTime('');
setEndTime('');
setCapacity('1');
setNote('');
}
return (
<form onSubmit={handleSubmit} style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'center' }}>
<input type="date" value={date} onChange={(e) => setDate(e.target.value)} disabled={busy} required />
<input
type="time"
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
disabled={busy}
required
style={{ width: '6rem' }}
/>
<input
type="time"
value={endTime}
onChange={(e) => setEndTime(e.target.value)}
disabled={busy}
required
style={{ width: '6rem' }}
/>
<input
type="number"
min={1}
value={capacity}
onChange={(e) => setCapacity(e.target.value)}
disabled={busy}
style={{ width: '4rem' }}
/>
<input
type="text"
placeholder="poznámka"
value={note}
onChange={(e) => setNote(e.target.value)}
disabled={busy}
style={{ flex: 1, minWidth: '8rem' }}
/>
<button className="btn" type="submit" disabled={busy}>
Přidat mimořádnou směnu
</button>
</form>
);
}
function SlotRow({
slot,
busy,
employees,
onSave,
onAssign,
onRemove,
}: {
slot: AdminShiftSlot;
busy: boolean;
employees: { id: number; email: string; name: string | null; active: 0 | 1 }[];
onSave: (data: { date: string; start_time: string; end_time: string; capacity: number; note?: string }) => Promise<void>;
onAssign: (employeeId: number) => Promise<void>;
onRemove: (employeeId: number) => Promise<void>;
}) {
const [capacity, setCapacity] = useState(String(slot.capacity));
const [note, setNote] = useState(slot.note);
const [pick, setPick] = useState('');
const dirty = Number(capacity) !== slot.capacity || note !== slot.note;
const assignedIds = new Set(slot.employees.map((e) => e.employee_id));
const candidates = employees.filter((e) => e.active === 1 && !assignedIds.has(e.id));
return (
<div className="row" style={{ flexWrap: 'wrap' }}>
<div className="row-main">
<div className="row-title">
{slot.start_time}{slot.end_time}{' '}
<span className={`badge ${slot.status === 'open' ? 'badge-dashed' : 'badge-solid'}`}>
{slot.status === 'open' ? 'volno' : slot.status === 'full' ? 'obsazeno' : 'uzavřeno'}
</span>
</div>
<div className="row-meta">
{slot.employees.length === 0 ? '· nikdo přihlášen' : slot.employees.map((e) => e.name).join(', ')}
</div>
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.4rem', flexWrap: 'wrap', alignItems: 'center' }}>
<input
type="number"
min={0}
value={capacity}
onChange={(e) => setCapacity(e.target.value)}
disabled={busy}
style={{ width: '3.5rem' }}
/>
<input
type="text"
placeholder="poznámka"
value={note}
onChange={(e) => setNote(e.target.value)}
disabled={busy}
style={{ width: '10rem' }}
/>
{dirty && (
<button
className="btn"
disabled={busy}
onClick={() =>
onSave({
date: slot.date,
start_time: slot.start_time,
end_time: slot.end_time,
capacity: Number(capacity),
note: note || undefined,
})
}
>
Uložit
</button>
)}
{slot.employees.map((e) => (
<span key={e.employee_id} className="badge">
{e.name}{' '}
<button className="btn-ghost" disabled={busy} onClick={() => onRemove(e.employee_id)}>
x
</button>
</span>
))}
{candidates.length > 0 && (
<>
<select value={pick} onChange={(e) => setPick(e.target.value)} disabled={busy}>
<option value="">přiřadit</option>
{candidates.map((e) => (
<option key={e.id} value={e.id}>
{e.name ?? e.email}
</option>
))}
</select>
<button
className="btn"
disabled={busy || !pick}
onClick={() => {
onAssign(Number(pick));
setPick('');
}}
>
Přiřadit
</button>
</>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,86 @@
import { useEffect, useMemo } from 'react';
import useShiftPlanningStore from '../store/shiftPlanningStore';
import { MonthNav } from './MonthNav';
import { MonthCalendar } from './MonthCalendar';
import type { AvailableShiftSlot, MyShiftSlot } from '../api/types';
export function ShiftPlanning() {
const period = useShiftPlanningStore((s) => s.period);
const available = useShiftPlanningStore((s) => s.available);
const mine = useShiftPlanningStore((s) => s.mine);
const busy = useShiftPlanningStore((s) => s.busy);
const error = useShiftPlanningStore((s) => s.error);
const load = useShiftPlanningStore((s) => s.load);
const prevPeriod = useShiftPlanningStore((s) => s.prevPeriod);
const nextPeriod = useShiftPlanningStore((s) => s.nextPeriod);
const signup = useShiftPlanningStore((s) => s.signup);
const cancel = useShiftPlanningStore((s) => s.cancel);
useEffect(() => {
load();
}, [load]);
const byDate = useMemo(() => {
const map = new Map<string, { mine: MyShiftSlot[]; available: AvailableShiftSlot[] }>();
const entryFor = (date: string) => {
let entry = map.get(date);
if (!entry) {
entry = { mine: [], available: [] };
map.set(date, entry);
}
return entry;
};
mine?.forEach((slot) => entryFor(slot.date).mine.push(slot));
available?.forEach((slot) => entryFor(slot.date).available.push(slot));
return map;
}, [mine, available]);
function renderDay(dateStr: string) {
const entry = byDate.get(dateStr);
if (!entry) return null;
return (
<>
{entry.mine.map((slot) => (
<button
key={`mine-${slot.slot_id}`}
className="calendar-slot is-mine"
disabled={busy}
title="Zrušit přihlášení"
onClick={() => cancel(slot.slot_id)}
>
{slot.start_time}{slot.end_time}
</button>
))}
{entry.available.map((slot) => (
<button
key={`avail-${slot.slot_id}`}
className="calendar-slot"
disabled={busy}
title="Přihlásit se"
onClick={() => signup(slot.slot_id)}
>
{slot.start_time}{slot.end_time} ({slot.free_places}/{slot.capacity})
</button>
))}
</>
);
}
return (
<div className="panel">
<p className="panel-title">Plánování směn</p>
<MonthNav month={period} onPrev={prevPeriod} onNext={nextPeriod} />
{error && <div className="login-error">{error}</div>}
{available === null || mine === null ? (
<div className="empty-line">&gt; načítám</div>
) : (
<MonthCalendar month={period} renderDay={renderDay} />
)}
<p className="row-meta" style={{ marginTop: '0.5rem' }}>
Tučně = moje směna, klikni pro zrušení &middot; ostatní = volná směna, klikni pro přihlášení
</p>
</div>
);
}

View File

@@ -20,3 +20,13 @@ export function formatDate(iso: string): string {
export function formatDateTime(iso: string): string {
return `${formatDate(iso)} ${formatTime(iso)}`;
}
/** Formats a "YYYY-MM-DD" date-only string, parsed as a local date (not UTC) so it never shifts by a day. */
export function formatDateOnly(dateOnly: string): string {
const [year, month, day] = dateOnly.split('-').map(Number);
return new Date(year, month - 1, day).toLocaleDateString('cs-CZ', {
weekday: 'short',
day: '2-digit',
month: '2-digit',
});
}

View File

@@ -1,6 +1,6 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './styles/tui.css';
import './styles/theme.css';
import App from './App.tsx';
createRoot(document.getElementById('root')!).render(

View File

@@ -1,8 +1,10 @@
import { useEffect, useState } from 'react';
import { EmployeeManager } from '../components/EmployeeManager';
import { AdminStats } from '../components/AdminStats';
import { ShiftPlanManager } from '../components/ShiftPlanManager';
import { PayrollManager } from '../components/PayrollManager';
type Tab = 'employees' | 'stats';
type Tab = 'employees' | 'stats' | 'shifts' | 'payroll';
export function AdminApp() {
const [tab, setTab] = useState<Tab>('employees');
@@ -13,6 +15,8 @@ export function AdminApp() {
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return;
if (e.key === '1') setTab('employees');
if (e.key === '2') setTab('stats');
if (e.key === '3') setTab('shifts');
if (e.key === '4') setTab('payroll');
}
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
@@ -30,10 +34,19 @@ export function AdminApp() {
<button className={`tab ${tab === 'stats' ? 'active' : ''}`} onClick={() => setTab('stats')}>
[2] Statistiky
</button>
<button className={`tab ${tab === 'shifts' ? 'active' : ''}`} onClick={() => setTab('shifts')}>
[3] Směny
</button>
<button className={`tab ${tab === 'payroll' ? 'active' : ''}`} onClick={() => setTab('payroll')}>
[4] Mzdy
</button>
</div>
<div style={{ marginTop: '1rem' }}>
{tab === 'employees' ? <EmployeeManager /> : <AdminStats />}
{tab === 'employees' && <EmployeeManager />}
{tab === 'stats' && <AdminStats />}
{tab === 'shifts' && <ShiftPlanManager />}
{tab === 'payroll' && <PayrollManager />}
</div>
</>
);

View File

@@ -4,6 +4,8 @@ import { ClockControls } from '../components/ClockControls';
import { StatusBadge } from '../components/StatusBadge';
import { StatsSummary } from '../components/StatsSummary';
import { SessionList } from '../components/SessionList';
import { ShiftPlanning } from '../components/ShiftPlanning';
import { ClosureCard } from '../components/ClosureCard';
export function EmployeeApp() {
const status = useAttendanceStore((s) => s.status);
@@ -53,9 +55,13 @@ export function EmployeeApp() {
</div>
<div className="panel">
<p className="panel-title">Směny</p>
<p className="panel-title">Odpracované směny</p>
<SessionList sessions={live.sessions} />
</div>
<ShiftPlanning />
<ClosureCard />
</>
);
}

View File

@@ -0,0 +1,108 @@
import { create } from 'zustand';
import { api, ApiError } from '../api/client';
import type { Payroll, PayrollOverviewRow } from '../api/types';
import { currentMonthKey, shiftMonthKey } from '../lib/month';
export interface Adjustments {
tips_amount: number;
bonus_amount: number;
other_amount: number;
}
interface AdminPayrollState {
period: string;
rows: PayrollOverviewRow[] | null;
busy: boolean;
error: string | null;
load: () => Promise<void>;
setPeriod: (period: string) => Promise<void>;
prevPeriod: () => Promise<void>;
nextPeriod: () => Promise<void>;
saveAdjustments: (employeeId: number, data: Adjustments) => Promise<void>;
lock: (employeeId: number) => Promise<void>;
reopen: (employeeId: number) => Promise<void>;
markPaid: (employeeId: number) => Promise<void>;
}
const useAdminPayrollStore = create<AdminPayrollState>((set, get) => ({
period: currentMonthKey(),
rows: null,
busy: false,
error: null,
load: async () => {
try {
const res = await api.get<{ rows: PayrollOverviewRow[] }>(`/admin/payroll?period=${get().period}`);
set({ rows: res.rows, error: null });
} catch {
set({ error: 'Nepodařilo se načíst mzdy' });
}
},
setPeriod: async (period) => {
set({ period });
await get().load();
},
prevPeriod: async () => {
await get().setPeriod(shiftMonthKey(get().period, -1));
},
nextPeriod: async () => {
await get().setPeriod(shiftMonthKey(get().period, 1));
},
saveAdjustments: async (employeeId, data) => {
set({ busy: true, error: null });
try {
await api.post<{ payroll: Payroll }>(`/admin/payroll/${employeeId}/adjustments`, {
period: get().period,
...data,
});
await get().load();
} catch (err) {
set({ error: err instanceof ApiError ? err.message : 'Uložení se nezdařilo' });
} finally {
set({ busy: false });
}
},
lock: async (employeeId) => {
set({ busy: true, error: null });
try {
await api.post(`/admin/payroll/${employeeId}/lock`, { period: get().period });
await get().load();
} catch (err) {
set({ error: err instanceof ApiError ? err.message : 'Uzamčení se nezdařilo' });
} finally {
set({ busy: false });
}
},
reopen: async (employeeId) => {
set({ busy: true, error: null });
try {
await api.post(`/admin/payroll/${employeeId}/reopen`, { period: get().period });
await get().load();
} catch (err) {
set({ error: err instanceof ApiError ? err.message : 'Odemknutí se nezdařilo' });
} finally {
set({ busy: false });
}
},
markPaid: async (employeeId) => {
set({ busy: true, error: null });
try {
await api.post(`/admin/payroll/${employeeId}/paid`, { period: get().period });
await get().load();
} catch (err) {
set({ error: err instanceof ApiError ? err.message : 'Označení se nezdařilo' });
} finally {
set({ busy: false });
}
},
}));
export default useAdminPayrollStore;

View File

@@ -0,0 +1,107 @@
import { create } from 'zustand';
import { api, ApiError } from '../api/client';
import type { AdminShiftSlot } from '../api/types';
import { currentMonthKey, shiftMonthKey } from '../lib/month';
export interface SlotFormData {
date: string;
start_time: string;
end_time: string;
capacity: number;
note?: string;
}
interface AdminShiftState {
period: string;
slots: AdminShiftSlot[] | null;
busy: boolean;
error: string | null;
load: () => Promise<void>;
setPeriod: (period: string) => Promise<void>;
prevPeriod: () => Promise<void>;
nextPeriod: () => Promise<void>;
createSlot: (data: SlotFormData) => Promise<void>;
updateSlot: (slotId: number, data: SlotFormData) => Promise<void>;
assignEmployee: (slotId: number, employeeId: number) => Promise<void>;
removeEmployee: (slotId: number, employeeId: number) => Promise<void>;
}
const useAdminShiftStore = create<AdminShiftState>((set, get) => ({
period: currentMonthKey(),
slots: null,
busy: false,
error: null,
load: async () => {
try {
const res = await api.get<{ slots: AdminShiftSlot[] }>(`/admin/shifts?period=${get().period}`);
set({ slots: res.slots, error: null });
} catch {
set({ error: 'Nepodařilo se načíst plán směn' });
}
},
setPeriod: async (period) => {
set({ period });
await get().load();
},
prevPeriod: async () => {
await get().setPeriod(shiftMonthKey(get().period, -1));
},
nextPeriod: async () => {
await get().setPeriod(shiftMonthKey(get().period, 1));
},
createSlot: async (data) => {
set({ busy: true, error: null });
try {
await api.post('/admin/shifts', data);
await get().load();
} catch (err) {
set({ error: err instanceof ApiError ? err.message : 'Vytvoření se nezdařilo' });
} finally {
set({ busy: false });
}
},
updateSlot: async (slotId, data) => {
set({ busy: true, error: null });
try {
await api.patch(`/admin/shifts/${slotId}`, data);
await get().load();
} catch (err) {
set({ error: err instanceof ApiError ? err.message : 'Úprava se nezdařila' });
} finally {
set({ busy: false });
}
},
assignEmployee: async (slotId, employeeId) => {
set({ busy: true, error: null });
try {
await api.post(`/admin/shifts/${slotId}/assign`, { employeeId });
await get().load();
} catch (err) {
set({ error: err instanceof ApiError ? err.message : 'Přiřazení se nezdařilo' });
} finally {
set({ busy: false });
}
},
removeEmployee: async (slotId, employeeId) => {
set({ busy: true, error: null });
try {
await api.delete(`/admin/shifts/${slotId}/assign/${employeeId}`);
await get().load();
} catch (err) {
set({ error: err instanceof ApiError ? err.message : 'Odebrání se nezdařilo' });
} finally {
set({ busy: false });
}
},
}));
export default useAdminShiftStore;

View File

@@ -1,10 +1,10 @@
import { create } from 'zustand';
import { api, ApiError } from '../api/client';
import type { AdminStatsSummary, Employee, StatsSummary } from '../api/types';
import type { AdminStatsSummary, EmployeeWithRate, StatsSummary } from '../api/types';
import { currentMonthKey, shiftMonthKey } from '../lib/month';
interface AdminState {
employees: Employee[] | null;
employees: EmployeeWithRate[] | null;
employeesError: string | null;
employeesBusy: boolean;
@@ -18,6 +18,7 @@ interface AdminState {
loadEmployees: () => Promise<void>;
addEmployee: (email: string) => Promise<void>;
removeEmployee: (id: number) => Promise<void>;
setRate: (id: number, hourlyRate: number) => Promise<void>;
loadStats: () => Promise<void>;
selectEmployee: (id: number) => Promise<void>;
@@ -40,13 +41,25 @@ const useAdminStore = create<AdminState>((set, get) => ({
loadEmployees: async () => {
try {
const res = await api.get<{ employees: Employee[] }>('/admin/employees');
const res = await api.get<{ employees: EmployeeWithRate[] }>('/admin/employees');
set({ employees: res.employees, employeesError: null });
} catch {
set({ employeesError: 'Nepodařilo se načíst zaměstnance' });
}
},
setRate: async (id, hourlyRate) => {
set({ employeesBusy: true, employeesError: null });
try {
await api.post(`/admin/employees/${id}/rate`, { hourly_rate: hourlyRate });
await get().loadEmployees();
} catch (err) {
set({ employeesError: err instanceof ApiError ? err.message : 'Uložení sazby se nezdařilo' });
} finally {
set({ employeesBusy: false });
}
},
addEmployee: async (email) => {
set({ employeesBusy: true, employeesError: null });
try {

View File

@@ -0,0 +1,64 @@
import { create } from 'zustand';
import { api, ApiError } from '../api/client';
import type { ClosureSummary, MonthClosure } from '../api/types';
import { currentMonthKey, shiftMonthKey } from '../lib/month';
interface ClosureState {
period: string;
closure: MonthClosure | null;
summary: ClosureSummary | null;
busy: boolean;
error: string | null;
load: () => Promise<void>;
setPeriod: (period: string) => Promise<void>;
prevPeriod: () => Promise<void>;
nextPeriod: () => Promise<void>;
confirm: () => Promise<void>;
}
const useClosureStore = create<ClosureState>((set, get) => ({
period: currentMonthKey(),
closure: null,
summary: null,
busy: false,
error: null,
load: async () => {
try {
const res = await api.get<{ closure: MonthClosure; summary: ClosureSummary }>(
`/closure?period=${get().period}`
);
set({ closure: res.closure, summary: res.summary, error: null });
} catch {
set({ error: 'Nepodařilo se načíst uzávěrku' });
}
},
setPeriod: async (period) => {
set({ period });
await get().load();
},
prevPeriod: async () => {
await get().setPeriod(shiftMonthKey(get().period, -1));
},
nextPeriod: async () => {
await get().setPeriod(shiftMonthKey(get().period, 1));
},
confirm: async () => {
set({ busy: true, error: null });
try {
await api.post('/closure/confirm', { period: get().period });
await get().load();
} catch (err) {
set({ error: err instanceof ApiError ? err.message : 'Potvrzení se nezdařilo' });
} finally {
set({ busy: false });
}
},
}));
export default useClosureStore;

View File

@@ -0,0 +1,79 @@
import { create } from 'zustand';
import { api, ApiError } from '../api/client';
import type { AvailableShiftSlot, MyShiftSlot } from '../api/types';
import { currentMonthKey, shiftMonthKey } from '../lib/month';
interface ShiftPlanningState {
period: string;
available: AvailableShiftSlot[] | null;
mine: MyShiftSlot[] | null;
busy: boolean;
error: string | null;
load: () => Promise<void>;
setPeriod: (period: string) => Promise<void>;
prevPeriod: () => Promise<void>;
nextPeriod: () => Promise<void>;
signup: (slotId: number) => Promise<void>;
cancel: (slotId: number) => Promise<void>;
}
const useShiftPlanningStore = create<ShiftPlanningState>((set, get) => ({
period: currentMonthKey(),
available: null,
mine: null,
busy: false,
error: null,
load: async () => {
try {
const period = get().period;
const [availableRes, mineRes] = await Promise.all([
api.get<{ slots: AvailableShiftSlot[] }>(`/shifts/available?period=${period}`),
api.get<{ slots: MyShiftSlot[] }>(`/shifts/mine?period=${period}`),
]);
set({ available: availableRes.slots, mine: mineRes.slots, error: null });
} catch {
set({ error: 'Nepodařilo se načíst směny' });
}
},
setPeriod: async (period) => {
set({ period });
await get().load();
},
prevPeriod: async () => {
await get().setPeriod(shiftMonthKey(get().period, -1));
},
nextPeriod: async () => {
await get().setPeriod(shiftMonthKey(get().period, 1));
},
signup: async (slotId) => {
set({ busy: true, error: null });
try {
await api.post(`/shifts/${slotId}/signup`);
await get().load();
} catch (err) {
set({ error: err instanceof ApiError ? err.message : 'Přihlášení se nezdařilo' });
} finally {
set({ busy: false });
}
},
cancel: async (slotId) => {
set({ busy: true, error: null });
try {
await api.delete(`/shifts/${slotId}/signup`);
await get().load();
} catch (err) {
set({ error: err instanceof ApiError ? err.message : 'Zrušení se nezdařilo' });
} finally {
set({ busy: false });
}
},
}));
export default useShiftPlanningStore;

View File

@@ -0,0 +1,735 @@
:root {
--bg: #0d0f13;
--surface: #15181f;
--surface-hover: #1c2029;
--surface-2: #1a1e26;
--border: #262b34;
--border-strong: #333a46;
--fg: #eef0f3;
--muted: #8d95a3;
--muted-2: #5f6675;
--accent: #f2a93b;
--accent-strong: #ffc670;
--accent-fg: #1a1206;
--radius-lg: 0;
--radius: 0;
--radius-sm: 0;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.35);
--shadow-md: 0 16px 40px -20px rgba(0, 0, 0, 0.65);
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Inter, Roboto, 'Helvetica Neue', Arial, sans-serif;
--font-mono: ui-monospace, '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: 14px;
line-height: 1.45;
font-variant-numeric: tabular-nums;
color-scheme: dark;
-webkit-font-smoothing: antialiased;
}
body.app {
overflow: hidden;
}
#root {
display: flex;
flex-direction: column;
}
a {
color: inherit;
}
button {
font-family: inherit;
font-size: inherit;
}
/* ---------- form controls ---------- */
input,
select {
font-family: inherit;
font-size: 13px;
color: var(--fg);
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 0.45rem 0.6rem;
outline: none;
}
input::placeholder {
color: var(--muted-2);
}
input:focus,
select:focus {
border-color: var(--accent);
}
input:disabled,
select:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ---------- shell ---------- */
.app-shell {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.chrome {
flex-shrink: 0;
background: var(--surface);
border-bottom: 1px solid var(--border);
}
.chrome-inner {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.85rem 1.25rem;
max-width: 72rem;
margin: 0 auto;
width: 100%;
}
.brand {
font-weight: 700;
font-size: 15px;
letter-spacing: -0.01em;
}
.brand-sub {
color: var(--muted);
font-size: 12px;
font-weight: 500;
margin-left: 0.5rem;
}
.chrome-user {
display: flex;
align-items: center;
gap: 1rem;
font-size: 12.5px;
color: var(--muted);
}
.scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.scroll-inner {
max-width: 72rem;
margin: 0 auto;
padding: 1.5rem 1.25rem 3rem;
}
/* ---------- tabs ---------- */
.tabs {
display: flex;
gap: 0.25rem;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.25rem;
}
.tab {
flex: 1;
padding: 0.5rem 0.75rem;
text-align: center;
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.01em;
background: transparent;
color: var(--muted);
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
}
.tab:hover {
color: var(--fg);
background: var(--surface-hover);
}
.tab.active {
background: var(--accent);
color: var(--accent-fg);
}
/* ---------- buttons ---------- */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
border: 1px solid var(--border-strong);
background: var(--surface-2);
color: var(--fg);
padding: 0.5rem 0.9rem;
font-weight: 600;
font-size: 12.5px;
border-radius: var(--radius-sm);
cursor: pointer;
text-decoration: none;
transition: background 0.15s ease, border-color 0.15s ease, transform 0.05s ease;
}
.btn:hover:not(:disabled) {
background: var(--surface-hover);
border-color: var(--muted-2);
}
.btn:active:not(:disabled) {
transform: translateY(1px);
}
.btn:disabled {
color: var(--muted-2);
cursor: not-allowed;
opacity: 0.6;
}
.btn-block {
width: 100%;
padding: 0.75rem 1rem;
font-size: 13.5px;
}
.btn-ghost {
border: none;
background: transparent;
color: var(--muted);
font-weight: 500;
padding: 0.3rem 0.4rem;
border-radius: var(--radius-sm);
}
.btn-ghost:hover:not(:disabled) {
color: var(--fg);
background: var(--surface-hover);
}
/* ---------- panels / bordered ---------- */
.bordered {
border: 1px solid var(--border);
border-radius: var(--radius);
}
.panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1.25rem;
box-shadow: var(--shadow-sm);
}
.panel + .panel {
margin-top: 1rem;
}
.panel-title {
text-transform: uppercase;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
color: var(--muted);
margin: 0 0 0.85rem;
}
/* ---------- field row (bordered input + button) ---------- */
.field-row {
display: flex;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
overflow: hidden;
}
.field-row input {
flex: 1;
border: none;
background: transparent;
padding: 0.6rem 0.8rem;
font-size: 13.5px;
min-width: 0;
}
.field-row .btn {
border: none;
border-left: 1px solid var(--border);
border-radius: 0;
}
/* ---------- dense list rows ---------- */
.list {
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.7rem 0.9rem;
background: var(--surface);
}
.row + .row {
border-top: 1px solid var(--border);
}
.row:hover {
background: var(--surface-hover);
}
.row-main {
min-width: 0;
flex: 1;
}
.row-title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 600;
font-size: 13.5px;
}
.row-meta {
color: var(--muted);
font-size: 12px;
margin-top: 0.2rem;
}
.row-action {
flex-shrink: 0;
display: flex;
gap: 0.5rem;
align-items: center;
}
/* ---------- badges ---------- */
.badge {
display: inline-flex;
align-items: center;
font-size: 10.5px;
font-weight: 600;
letter-spacing: 0.02em;
padding: 0.2rem 0.55rem;
border-radius: 0;
background: var(--surface-2);
color: var(--muted);
border: 1px solid var(--border);
}
.badge-solid {
background: var(--accent);
color: var(--accent-fg);
border-color: transparent;
font-weight: 700;
}
.badge-dashed {
background: transparent;
color: var(--muted);
border: 1px solid var(--border-strong);
}
/* ---------- stat tiles ---------- */
.stat-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
gap: 1px;
background: var(--border);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.stat-tile {
padding: 0.9rem 1rem;
background: var(--surface);
}
.stat-value {
font-family: var(--font-mono);
font-size: 22px;
font-weight: 700;
letter-spacing: -0.01em;
}
.stat-label {
color: var(--muted);
font-size: 10.5px;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-top: 0.25rem;
}
/* ---------- login ---------- */
.login-shell {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
}
.login-card {
width: 100%;
max-width: 23rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 2.25rem 2rem;
text-align: center;
box-shadow: var(--shadow-md);
}
.login-logo {
width: 88px;
height: 88px;
margin: 0 auto 1.1rem;
display: block;
border-radius: var(--radius);
border: 1px solid var(--border);
}
.login-title {
font-weight: 700;
font-size: 17px;
letter-spacing: -0.01em;
margin: 0 0 0.3rem;
}
.login-sub {
color: var(--muted);
font-size: 12.5px;
margin: 0 0 1.75rem;
}
.google-btn-slot {
display: flex;
justify-content: center;
margin: 0 auto;
}
.login-error {
margin-top: 1.25rem;
font-size: 12px;
color: #ffb4b4;
background: rgba(239, 106, 106, 0.1);
border: 1px solid rgba(239, 106, 106, 0.3);
border-radius: var(--radius-sm);
padding: 0.6rem 0.75rem;
text-align: left;
}
/* ---------- month nav ---------- */
.month-nav {
display: flex;
align-items: stretch;
gap: 0.25rem;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.25rem;
margin-bottom: 1rem;
}
.month-nav .btn {
border: none;
background: transparent;
flex-shrink: 0;
}
.month-nav .btn:hover:not(:disabled) {
background: var(--surface-hover);
}
.month-nav-label {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
font-size: 12.5px;
font-weight: 700;
letter-spacing: 0.02em;
text-transform: capitalize;
}
/* ---------- bar chart ---------- */
.bar-chart-wrap {
display: flex;
gap: 0.75rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1rem 0.75rem 0.75rem;
}
.bar-chart-yaxis {
flex-shrink: 0;
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: flex-end;
height: 140px;
padding-right: 0.6rem;
border-right: 1px solid var(--border);
color: var(--muted);
font-size: 10px;
font-family: var(--font-mono);
line-height: 1;
}
.bar-chart-body {
flex: 1;
min-width: 0;
}
.bar-chart {
display: flex;
align-items: flex-end;
gap: 3px;
height: 140px;
background-image: repeating-linear-gradient(
to top,
var(--border) 0,
var(--border) 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: 3px;
}
.bar {
background: var(--accent);
border-radius: 0;
min-height: 2px;
opacity: 0.85;
transition: opacity 0.12s ease;
}
.bar-col:hover .bar {
opacity: 1;
}
.bar-chart-labels {
display: flex;
gap: 3px;
margin-top: 0.35rem;
}
.bar-col-label {
flex: 1;
min-width: 3px;
color: var(--muted);
font-size: 9.5px;
text-align: center;
line-height: 1;
font-family: var(--font-mono);
}
/* ---------- empty / loading ---------- */
.empty-line {
color: var(--muted);
padding: 0.85rem;
font-size: 13px;
}
/* ---------- focus ---------- */
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: 0;
}
/* ---------- month calendar ---------- */
.month-calendar-scroll {
overflow-x: auto;
}
.month-calendar {
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
min-width: 30rem;
}
.month-calendar-head {
display: grid;
grid-template-columns: repeat(7, 1fr);
background: var(--surface-2);
border-bottom: 1px solid var(--border);
}
.month-calendar-head div {
padding: 0.5rem;
text-align: center;
text-transform: uppercase;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.04em;
color: var(--muted);
}
.month-calendar-body {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 1px;
background: var(--border);
}
.month-calendar-cell {
min-height: 4.75rem;
padding: 0.35rem;
background: var(--surface);
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.month-calendar-cell.is-outside {
background: var(--bg);
}
.month-calendar-cell.is-today {
box-shadow: inset 0 0 0 1.5px var(--accent);
}
.month-calendar-daynum {
font-size: 10.5px;
text-align: right;
color: var(--muted);
font-family: var(--font-mono);
}
.month-calendar-cell.is-today .month-calendar-daynum {
color: var(--accent);
font-weight: 700;
}
.month-calendar-day-btn {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.2rem;
border: none;
background: transparent;
padding: 0;
cursor: pointer;
text-align: left;
font-family: inherit;
border-radius: var(--radius-sm);
}
.month-calendar-day-btn.is-selected {
box-shadow: inset 0 0 0 1.5px var(--accent);
}
.calendar-slot {
display: block;
width: 100%;
font-family: var(--font-mono);
font-size: 9.5px;
line-height: 1.3;
padding: 0.15rem 0.35rem;
border-radius: 0;
border: 1px solid var(--border);
background: var(--surface-2);
color: var(--fg);
cursor: pointer;
text-align: left;
}
.calendar-slot:hover:not(:disabled) {
background: var(--surface-hover);
border-color: var(--border-strong);
}
.calendar-slot:disabled {
cursor: not-allowed;
color: var(--muted-2);
}
.calendar-slot.is-mine {
background: var(--accent);
color: var(--accent-fg);
border-color: transparent;
font-weight: 700;
}
.calendar-chip {
display: block;
font-size: 9.5px;
line-height: 1.3;
padding: 0.15rem 0.35rem;
border-radius: 0;
border: 1px solid var(--border);
background: var(--surface-2);
color: var(--muted);
font-family: var(--font-mono);
}
.calendar-chip.full {
background: var(--accent);
color: var(--accent-fg);
font-weight: 700;
border-color: transparent;
}
.calendar-chip.closed {
opacity: 0.55;
}

View File

@@ -1,520 +0,0 @@
: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;
}

View File

@@ -13,8 +13,8 @@ export default defineConfig({
name: 'EatMe — Docházka',
short_name: 'EatMe',
description: 'Evidence docházky zaměstnanců bistra EatMe',
theme_color: '#000000',
background_color: '#f0f0f0',
theme_color: '#0d0f13',
background_color: '#0d0f13',
display: 'standalone',
start_url: '/',
icons: [