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:
@@ -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>
|
||||
|
||||
@@ -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' }),
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
66
frontend/src/components/ClosureCard.tsx
Normal file
66
frontend/src/components/ClosureCard.tsx
Normal 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">> 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} Kč</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>
|
||||
);
|
||||
}
|
||||
@@ -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">> 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>
|
||||
<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} · 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 }}>
|
||||
Kč/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>
|
||||
);
|
||||
}
|
||||
|
||||
61
frontend/src/components/MonthCalendar.tsx
Normal file
61
frontend/src/components/MonthCalendar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
169
frontend/src/components/PayrollManager.tsx
Normal file
169
frontend/src/components/PayrollManager.tsx
Normal 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">> 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)} · základ {row.base_amount} Kč · celkem{' '}
|
||||
{row.final_amount} Kč
|
||||
</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>
|
||||
);
|
||||
}
|
||||
274
frontend/src/components/ShiftPlanManager.tsx
Normal file
274
frontend/src/components/ShiftPlanManager.tsx
Normal 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">> 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>
|
||||
);
|
||||
}
|
||||
86
frontend/src/components/ShiftPlanning.tsx
Normal file
86
frontend/src/components/ShiftPlanning.tsx
Normal 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">> 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í · ostatní = volná směna, klikni pro přihlášení
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
108
frontend/src/store/adminPayrollStore.ts
Normal file
108
frontend/src/store/adminPayrollStore.ts
Normal 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;
|
||||
107
frontend/src/store/adminShiftStore.ts
Normal file
107
frontend/src/store/adminShiftStore.ts
Normal 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;
|
||||
@@ -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 {
|
||||
|
||||
64
frontend/src/store/closureStore.ts
Normal file
64
frontend/src/store/closureStore.ts
Normal 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;
|
||||
79
frontend/src/store/shiftPlanningStore.ts
Normal file
79
frontend/src/store/shiftPlanningStore.ts
Normal 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;
|
||||
735
frontend/src/styles/theme.css
Normal file
735
frontend/src/styles/theme.css
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user