feat: nová stránka pro návrhy na vylepšení
CI / Generate TypeScript types (push) Successful in 10s
CI / Server unit tests (push) Successful in 21s
CI / Build server (push) Successful in 24s
CI / Build client (push) Successful in 38s
CI / Playwright E2E tests (push) Successful in 1m18s
CI / Build and push Docker image (push) Successful in 40s
CI / Notify (push) Successful in 2s

This commit is contained in:
2026-06-05 19:15:46 +02:00
parent f28f127a92
commit 17132d4124
27 changed files with 857 additions and 515 deletions
+7
View File
@@ -6,15 +6,22 @@ import { ToastContainer } from "react-toastify";
import { SocketContext, socket } from "./context/socket";
import StatsPage from "./pages/StatsPage";
import OrderGroupsPage from "./pages/OrderGroupsPage";
import SuggestionsPage from "./pages/SuggestionsPage";
import App from "./App";
export const STATS_URL = '/stats';
export const OBJEDNANI_URL = '/objednani';
export const NAVRHY_URL = '/navrhy';
export default function AppRoutes() {
return (
<Routes>
<Route path={STATS_URL} element={<StatsPage />} />
<Route path={NAVRHY_URL} element={
<ProvideSettings>
<SuggestionsPage />
</ProvideSettings>
} />
<Route path={OBJEDNANI_URL} element={
<ProvideSettings>
<SocketContext.Provider value={socket}>
+3 -30
View File
@@ -4,15 +4,14 @@ import { useAuth } from "../context/auth";
import SettingsModal from "./modals/SettingsModal";
import { useSettings, ThemePreference } from "../context/settings";
import HuePicker from "./HuePicker";
import FeaturesVotingModal from "./modals/FeaturesVotingModal";
import PizzaCalculatorModal from "./modals/PizzaCalculatorModal";
import RefreshMenuModal from "./modals/RefreshMenuModal";
import GenerateQrModal from "./modals/GenerateQrModal";
import GenerateMockDataModal from "./modals/GenerateMockDataModal";
import ClearMockDataModal from "./modals/ClearMockDataModal";
import { useNavigate } from "react-router";
import { STATS_URL, OBJEDNANI_URL } from "../AppRoutes";
import { FeatureRequest, getVotes, updateVote, LunchChoices, getChangelogs } from "../../../types";
import { STATS_URL, OBJEDNANI_URL, NAVRHY_URL } from "../AppRoutes";
import { LunchChoices, getChangelogs } from "../../../types";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faSun, faMoon } from "@fortawesome/free-solid-svg-icons";
import { formatDateString } from "../Utils";
@@ -31,7 +30,6 @@ export default function Header({ choices, dayIndex }: Props) {
const settings = useSettings();
const navigate = useNavigate();
const [settingsModalOpen, setSettingsModalOpen] = useState<boolean>(false);
const [votingModalOpen, setVotingModalOpen] = useState<boolean>(false);
const [pizzaModalOpen, setPizzaModalOpen] = useState<boolean>(false);
const [refreshMenuModalOpen, setRefreshMenuModalOpen] = useState<boolean>(false);
const [changelogModalOpen, setChangelogModalOpen] = useState<boolean>(false);
@@ -39,18 +37,9 @@ export default function Header({ choices, dayIndex }: Props) {
const [qrModalOpen, setQrModalOpen] = useState<boolean>(false);
const [generateMockModalOpen, setGenerateMockModalOpen] = useState<boolean>(false);
const [clearMockModalOpen, setClearMockModalOpen] = useState<boolean>(false);
const [featureVotes, setFeatureVotes] = useState<FeatureRequest[] | undefined>([]);
const effectiveDark = settings?.effectiveDark ?? false;
useEffect(() => {
if (auth?.login) {
getVotes().then(response => {
setFeatureVotes(response.data);
})
}
}, [auth?.login]);
useEffect(() => {
if (!auth?.login) return;
const lastSeen = localStorage.getItem(LAST_SEEN_CHANGELOG_KEY) ?? undefined;
@@ -68,10 +57,6 @@ export default function Header({ choices, dayIndex }: Props) {
setSettingsModalOpen(false);
}
const closeVotingModal = () => {
setVotingModalOpen(false);
}
const closePizzaModal = () => {
setPizzaModalOpen(false);
}
@@ -158,17 +143,6 @@ export default function Header({ choices, dayIndex }: Props) {
closeSettingsModal();
}
const saveFeatureVote = async (option: FeatureRequest, active: boolean) => {
await updateVote({ body: { option, active } });
const votes = [...featureVotes || []];
if (active) {
votes.push(option);
} else {
votes.splice(votes.indexOf(option), 1);
}
setFeatureVotes(votes);
}
return <Navbar variant='dark' expand="lg">
<Navbar.Brand href="/">Luncher</Navbar.Brand>
<Navbar.Toggle aria-controls="basic-navbar-nav" />
@@ -190,7 +164,7 @@ export default function Header({ choices, dayIndex }: Props) {
<NavDropdown align="end" title={auth?.login} id="basic-nav-dropdown">
<NavDropdown.Item onClick={() => setSettingsModalOpen(true)}>Nastavení</NavDropdown.Item>
<NavDropdown.Item onClick={() => setRefreshMenuModalOpen(true)}>Přenačtení menu</NavDropdown.Item>
<NavDropdown.Item onClick={() => setVotingModalOpen(true)}>Hlasovat o nových funkcích</NavDropdown.Item>
<NavDropdown.Item onClick={() => navigate(NAVRHY_URL)}>Návrhy na vylepšení</NavDropdown.Item>
<NavDropdown.Item onClick={() => setPizzaModalOpen(true)}>Pizza kalkulačka</NavDropdown.Item>
<NavDropdown.Item onClick={handleQrMenuClick}>Generování QR kódů</NavDropdown.Item>
<NavDropdown.Item onClick={() => navigate(STATS_URL)}>Statistiky</NavDropdown.Item>
@@ -220,7 +194,6 @@ export default function Header({ choices, dayIndex }: Props) {
</Navbar.Collapse>
<SettingsModal isOpen={settingsModalOpen} onClose={closeSettingsModal} onSave={saveSettings} />
<RefreshMenuModal isOpen={refreshMenuModalOpen} onClose={closeRefreshMenuModal} />
<FeaturesVotingModal isOpen={votingModalOpen} onClose={closeVotingModal} onChange={saveFeatureVote} initialValues={featureVotes} />
<PizzaCalculatorModal isOpen={pizzaModalOpen} onClose={closePizzaModal} />
{choices && settings?.bankAccount && settings?.holderName && (
<GenerateQrModal
@@ -0,0 +1,79 @@
import { useState } from "react";
import { Modal, Button, Form } from "react-bootstrap";
type Props = {
isOpen: boolean;
onClose: () => void;
onSubmit: (title: string, description: string) => Promise<void>;
};
/** Modální dialog pro přidání nového návrhu na vylepšení. */
export default function AddSuggestionModal({ isOpen, onClose, onSubmit }: Readonly<Props>) {
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [submitting, setSubmitting] = useState(false);
const reset = () => {
setTitle("");
setDescription("");
};
const handleClose = () => {
reset();
onClose();
};
const handleSubmit = async () => {
if (!title.trim() || !description.trim()) return;
setSubmitting(true);
try {
await onSubmit(title.trim(), description.trim());
reset();
onClose();
} finally {
setSubmitting(false);
}
};
return (
<Modal show={isOpen} onHide={handleClose} size="lg">
<Modal.Header closeButton>
<Modal.Title><h2>Nový návrh na vylepšení</h2></Modal.Title>
</Modal.Header>
<Modal.Body>
<Form.Group className="mb-3">
<Form.Label>Název</Form.Label>
<Form.Control
type="text"
placeholder="Stručný název návrhu"
value={title}
maxLength={120}
onChange={e => setTitle(e.target.value)}
onKeyDown={e => e.stopPropagation()}
autoFocus
/>
<Form.Text className="text-muted">Krátký, výstižný název navrhované úpravy.</Form.Text>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Popis</Form.Label>
<Form.Control
as="textarea"
rows={5}
placeholder="Detailní popis navrhované úpravy, řešení apod."
value={description}
onChange={e => setDescription(e.target.value)}
onKeyDown={e => e.stopPropagation()}
/>
</Form.Group>
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={handleClose}>
Storno
</Button>
<Button onClick={handleSubmit} disabled={submitting || !title.trim() || !description.trim()}>
Přidat
</Button>
</Modal.Footer>
</Modal>
);
}
@@ -1,45 +0,0 @@
import { Modal, Button, Form } from "react-bootstrap"
import { FeatureRequest } from "../../../../types";
type Props = {
isOpen: boolean,
onClose: () => void,
onChange: (option: FeatureRequest, active: boolean) => void,
initialValues?: FeatureRequest[],
}
/** Modální dialog pro hlasování o nových funkcích. */
export default function FeaturesVotingModal({ isOpen, onClose, onChange, initialValues }: Readonly<Props>) {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onChange(e.currentTarget.value as FeatureRequest, e.currentTarget.checked);
}
return <Modal show={isOpen} onHide={onClose} size="lg">
<Modal.Header closeButton>
<Modal.Title>
Hlasujte pro nové funkce
<p style={{ fontSize: '12px' }}>Je možno vybrat maximálně 4 možnosti</p>
</Modal.Title>
</Modal.Header>
<Modal.Body>
{(Object.keys(FeatureRequest) as Array<keyof typeof FeatureRequest>).map(key => {
return <Form.Check
key={key}
type='checkbox'
id={key}
label={FeatureRequest[key]}
onChange={handleChange}
value={key}
defaultChecked={initialValues?.includes(key as FeatureRequest)}
/>
})}
<p className="mt-3" style={{ fontSize: '12px' }}>Něco jiného? Dejte vědět.</p>
</Modal.Body>
<Modal.Footer>
<Button variant="primary" onClick={onClose}>
Zavřít
</Button>
</Modal.Footer>
</Modal>
}
@@ -0,0 +1,29 @@
import { Modal, Button } from "react-bootstrap";
import { Suggestion } from "../../../../types";
type Props = {
suggestion?: Suggestion;
onClose: () => void;
};
/** Modální dialog zobrazující celý detail návrhu na vylepšení. */
export default function SuggestionDetailModal({ suggestion, onClose }: Readonly<Props>) {
return (
<Modal show={!!suggestion} onHide={onClose} size="lg">
<Modal.Header closeButton>
<Modal.Title><h2>{suggestion?.title}</h2></Modal.Title>
</Modal.Header>
<Modal.Body>
<p className="text-muted mb-3">
Navrhovatel: <strong>{suggestion?.author}</strong> · Hlasy: <strong>{suggestion?.voteScore}</strong>
</p>
<p style={{ whiteSpace: "pre-wrap" }}>{suggestion?.description}</p>
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={onClose}>
Zavřít
</Button>
</Modal.Footer>
</Modal>
);
}
-63
View File
@@ -89,67 +89,4 @@
.recharts-cartesian-grid-vertical line {
stroke: var(--luncher-border);
}
.voting-stats-section {
margin-top: 48px;
width: 100%;
max-width: 800px;
h2 {
font-size: 1.5rem;
font-weight: 700;
color: var(--luncher-text);
margin-bottom: 16px;
text-align: center;
}
}
.voting-stats-table {
width: 100%;
background: var(--luncher-bg-card);
border-radius: var(--luncher-radius-lg);
box-shadow: var(--luncher-shadow);
border: 1px solid var(--luncher-border-light);
overflow: hidden;
border-collapse: collapse;
th {
background: var(--luncher-primary);
color: #ffffff;
padding: 12px 20px;
text-align: left;
font-weight: 600;
font-size: 0.9rem;
&:last-child {
text-align: center;
width: 120px;
}
}
td {
padding: 12px 20px;
border-bottom: 1px solid var(--luncher-border-light);
color: var(--luncher-text);
font-size: 0.9rem;
&:last-child {
text-align: center;
font-weight: 600;
color: var(--luncher-primary);
}
}
tbody tr {
transition: var(--luncher-transition);
&:hover {
background: var(--luncher-bg-hover);
}
&:last-child td {
border-bottom: none;
}
}
}
}
+1 -36
View File
@@ -4,7 +4,7 @@ import Header from "../components/Header";
import { useAuth } from "../context/auth";
import Login from "../Login";
import { formatDate, getFirstWorkDayOfWeek, getHumanDate, getLastWorkDayOfWeek } from "../Utils";
import { WeeklyStats, LunchChoice, VotingStats, FeatureRequest, getStats, getVotingStats } from "../../../types";
import { WeeklyStats, LunchChoice, getStats } from "../../../types";
import Loader from "../components/Loader";
import { faChevronLeft, faChevronRight, faGear } from "@fortawesome/free-solid-svg-icons";
import { Legend, Line, LineChart, Tooltip, XAxis, YAxis } from "recharts";
@@ -32,7 +32,6 @@ export default function StatsPage() {
const auth = useAuth();
const [dateRange, setDateRange] = useState<Date[]>();
const [data, setData] = useState<WeeklyStats>();
const [votingStats, setVotingStats] = useState<VotingStats>();
// Prvotní nastavení aktuálního týdne
useEffect(() => {
@@ -49,19 +48,6 @@ export default function StatsPage() {
}
}, [dateRange]);
// Načtení statistik hlasování
useEffect(() => {
getVotingStats().then(response => {
setVotingStats(response.data);
});
}, []);
const sortedVotingStats = useMemo(() => {
if (!votingStats) return [];
return Object.entries(votingStats)
.sort((a, b) => (b[1] as number) - (a[1] as number));
}, [votingStats]);
const renderLine = (location: LunchChoice) => {
const index = Object.values(LunchChoice).indexOf(location);
return <Line key={location} name={getLunchChoiceName(location)} type="monotone" dataKey={data => data.locations[location] ?? 0} stroke={COLORS[index]} strokeWidth={STROKE_WIDTH} />
@@ -142,27 +128,6 @@ export default function StatsPage() {
<Tooltip />
<Legend />
</LineChart>
{sortedVotingStats.length > 0 && (
<div className="voting-stats-section">
<h2>Hlasování o funkcích</h2>
<table className="voting-stats-table">
<thead>
<tr>
<th>Funkce</th>
<th>Počet hlasů</th>
</tr>
</thead>
<tbody>
{sortedVotingStats.map(([feature, count]) => (
<tr key={feature}>
<td>{FeatureRequest[feature as keyof typeof FeatureRequest] ?? feature}</td>
<td>{count as number}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<Footer />
</>
+122
View File
@@ -0,0 +1,122 @@
.suggestions-page {
display: flex;
flex-direction: column;
align-items: center;
padding: 32px 24px;
min-height: calc(100vh - 140px);
background: var(--luncher-bg);
.suggestions-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
width: 100%;
max-width: 900px;
flex-wrap: wrap;
h1 {
font-size: 2rem;
font-weight: 700;
color: var(--luncher-text);
margin: 0;
}
}
.suggestions-info {
width: 100%;
max-width: 900px;
margin: 12px 0 24px;
color: var(--luncher-text-secondary);
font-size: 0.95rem;
}
.suggestions-empty {
color: var(--luncher-text-secondary);
margin-top: 32px;
}
.suggestions-table {
width: 100%;
max-width: 900px;
background: var(--luncher-bg-card);
border-radius: var(--luncher-radius-lg);
box-shadow: var(--luncher-shadow);
border: 1px solid var(--luncher-border-light);
overflow: hidden;
border-collapse: collapse;
th {
background: var(--luncher-primary);
color: #ffffff;
padding: 12px 20px;
text-align: left;
font-weight: 600;
font-size: 0.9rem;
}
td {
padding: 12px 20px;
border-bottom: 1px solid var(--luncher-border-light);
color: var(--luncher-text);
font-size: 0.9rem;
vertical-align: middle;
}
.col-score {
text-align: center;
width: 80px;
font-weight: 600;
}
td.col-score {
color: var(--luncher-primary);
}
.col-actions {
text-align: center;
width: 150px;
white-space: nowrap;
}
tbody tr {
cursor: pointer;
transition: var(--luncher-transition);
&:hover {
background: var(--luncher-bg-hover);
}
&:last-child td {
border-bottom: none;
}
}
.vote-btn {
background: transparent;
border: none;
cursor: pointer;
padding: 6px 8px;
border-radius: var(--luncher-radius-sm, 6px);
color: var(--luncher-text-secondary);
transition: var(--luncher-transition);
&:hover {
background: var(--luncher-bg-hover);
color: var(--luncher-text);
}
&.vote-up.active {
color: #2e7d32;
}
&.vote-down.active {
color: #c62828;
}
&.delete-btn:hover {
color: #c62828;
}
}
}
}
+150
View File
@@ -0,0 +1,150 @@
import { useCallback, useEffect, useState } from "react";
import { Button, OverlayTrigger, Tooltip } from "react-bootstrap";
import { ToastContainer } from "react-toastify";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faThumbsUp, faThumbsDown, faTrash, faPlus, faGear } from "@fortawesome/free-solid-svg-icons";
import Header from "../components/Header";
import Footer from "../components/Footer";
import Loader from "../components/Loader";
import { useAuth } from "../context/auth";
import Login from "../Login";
import AddSuggestionModal from "../components/modals/AddSuggestionModal";
import SuggestionDetailModal from "../components/modals/SuggestionDetailModal";
import {
Suggestion,
VoteDirection,
listSuggestions,
addSuggestion,
voteSuggestion,
deleteSuggestion,
} from "../../../types";
import "./SuggestionsPage.scss";
export default function SuggestionsPage() {
const auth = useAuth();
const [suggestions, setSuggestions] = useState<Suggestion[]>();
const [addModalOpen, setAddModalOpen] = useState(false);
const [detail, setDetail] = useState<Suggestion>();
const reload = useCallback(async () => {
if (!auth?.login) return;
const response = await listSuggestions();
setSuggestions(response.data ?? []);
}, [auth?.login]);
useEffect(() => {
reload();
}, [reload]);
const handleAdd = async (title: string, description: string) => {
const response = await addSuggestion({ body: { title, description } });
if (response.data) {
setSuggestions(response.data);
}
};
const handleVote = async (id: string, direction: VoteDirection) => {
const response = await voteSuggestion({ body: { id, direction } });
if (response.data) {
setSuggestions(response.data);
}
};
const handleDelete = async (suggestion: Suggestion) => {
if (!window.confirm(`Opravdu chcete smazat návrh „${suggestion.title}“? Smažou se i všechny jeho hlasy.`)) {
return;
}
const response = await deleteSuggestion({ body: { id: suggestion.id } });
if (response.data) {
setSuggestions(response.data);
}
};
if (!auth?.login) {
return <Login />;
}
if (!suggestions) {
return <Loader icon={faGear} description={"Načítám návrhy..."} animation={"fa-bounce"} />;
}
return (
<>
<Header />
<div className="suggestions-page">
<div className="suggestions-header">
<h1>Návrhy na vylepšení</h1>
<Button onClick={() => setAddModalOpen(true)}>
<FontAwesomeIcon icon={faPlus} /> Přidat návrh
</Button>
</div>
<p className="suggestions-info">
Zde můžete navrhovat vylepšení aplikace a hlasovat o návrzích ostatních. U každého návrhu je
zobrazeno jméno navrhovatele. Jména hlasujících jsou dostupná pouze administrátorům.
</p>
{suggestions.length === 0 ? (
<p className="suggestions-empty">Zatím nebyly přidány žádné návrhy. Buďte první!</p>
) : (
<table className="suggestions-table">
<thead>
<tr>
<th>Navrhovatel</th>
<th>Název</th>
<th className="col-score">Hlasy</th>
<th className="col-actions">Akce</th>
</tr>
</thead>
<tbody>
{suggestions.map(suggestion => (
<OverlayTrigger
key={suggestion.id}
placement="top"
overlay={<Tooltip id={`tooltip-${suggestion.id}`}>{suggestion.description}</Tooltip>}
>
<tr onClick={() => setDetail(suggestion)}>
<td>{suggestion.author}</td>
<td>{suggestion.title}</td>
<td className="col-score">{suggestion.voteScore}</td>
<td className="col-actions" onClick={e => e.stopPropagation()}>
<button
type="button"
className={`vote-btn vote-up ${suggestion.myVote === VoteDirection.UP ? "active" : ""}`}
title="Hlasovat pro"
onClick={() => handleVote(suggestion.id, VoteDirection.UP)}
>
<FontAwesomeIcon icon={faThumbsUp} />
</button>
<button
type="button"
className={`vote-btn vote-down ${suggestion.myVote === VoteDirection.DOWN ? "active" : ""}`}
title="Hlasovat proti"
onClick={() => handleVote(suggestion.id, VoteDirection.DOWN)}
>
<FontAwesomeIcon icon={faThumbsDown} />
</button>
{suggestion.isMine && (
<button
type="button"
className="vote-btn delete-btn"
title="Smazat návrh"
onClick={() => handleDelete(suggestion)}
>
<FontAwesomeIcon icon={faTrash} />
</button>
)}
</td>
</tr>
</OverlayTrigger>
))}
</tbody>
</table>
)}
</div>
<Footer />
<AddSuggestionModal isOpen={addModalOpen} onClose={() => setAddModalOpen(false)} onSubmit={handleAdd} />
<SuggestionDetailModal suggestion={detail} onClose={() => setDetail(undefined)} />
<ToastContainer />
</>
);
}