1 Commits

Author SHA1 Message Date
Stánek Pavel
e404b8112d fix: opravy zobrazeni sekce vybranych jidel 2026-02-04 12:28:26 +01:00
19 changed files with 34 additions and 713 deletions

View File

@@ -334,13 +334,6 @@ body {
color: rgba(255, 255, 255, 0.7); color: rgba(255, 255, 255, 0.7);
font-size: 0.75rem; font-size: 0.75rem;
} }
.restaurant-warning {
color: #f59e0b;
cursor: help;
margin-left: 8px;
font-size: 1rem;
}
} }
.restaurant-closed { .restaurant-closed {

View File

@@ -13,12 +13,12 @@ import './App.scss';
import { faCircleCheck, faNoteSticky, faTrashCan, faComment } from '@fortawesome/free-regular-svg-icons'; import { faCircleCheck, faNoteSticky, faTrashCan, faComment } from '@fortawesome/free-regular-svg-icons';
import { useSettings } from './context/settings'; import { useSettings } from './context/settings';
import Footer from './components/Footer'; import Footer from './components/Footer';
import { faBasketShopping, faChainBroken, faChevronLeft, faChevronRight, faGear, faSatelliteDish, faSearch, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons'; import { faBasketShopping, faChainBroken, faChevronLeft, faChevronRight, faGear, faSatelliteDish, faSearch } from '@fortawesome/free-solid-svg-icons';
import Loader from './components/Loader'; import Loader from './components/Loader';
import { getHumanDateTime, isInTheFuture } from './Utils'; import { getHumanDateTime, isInTheFuture } from './Utils';
import NoteModal from './components/modals/NoteModal'; import NoteModal from './components/modals/NoteModal';
import { useEasterEgg } from './context/eggs'; import { useEasterEgg } from './context/eggs';
import { ClientData, Food, PizzaOrder, DepartureTime, PizzaDayState, Restaurant, RestaurantDayMenu, RestaurantDayMenuMap, LunchChoice, UserLunchChoice, PizzaVariant, getData, getEasterEggImage, addPizza, removePizza, updatePizzaDayNote, createPizzaDay, deletePizzaDay, lockPizzaDay, unlockPizzaDay, finishOrder, finishDelivery, addChoice, jdemeObed, removeChoices, removeChoice, updateNote, changeDepartureTime, setBuyer, dismissQr } from '../../types'; import { ClientData, Food, PizzaOrder, DepartureTime, PizzaDayState, Restaurant, RestaurantDayMenu, RestaurantDayMenuMap, LunchChoice, UserLunchChoice, PizzaVariant, getData, getEasterEggImage, addPizza, removePizza, updatePizzaDayNote, createPizzaDay, deletePizzaDay, lockPizzaDay, unlockPizzaDay, finishOrder, finishDelivery, addChoice, jdemeObed, removeChoices, removeChoice, updateNote, changeDepartureTime, setBuyer } from '../../types';
import { getLunchChoiceName } from './enums'; import { getLunchChoiceName } from './enums';
// import FallingLeaves, { LEAF_PRESETS, LEAF_COLOR_THEMES } from './FallingLeaves'; // import FallingLeaves, { LEAF_PRESETS, LEAF_COLOR_THEMES } from './FallingLeaves';
// import './FallingLeaves.scss'; // import './FallingLeaves.scss';
@@ -138,33 +138,9 @@ function App() {
}, [socket]); }, [socket]);
useEffect(() => { useEffect(() => {
if (!auth?.login || !data?.choices) { if (!auth?.login) {
return return
} }
// Pre-fill form refs from existing choices
let foundKey: LunchChoice | undefined;
let foundChoice: UserLunchChoice | undefined;
for (const key of Object.keys(data.choices)) {
const locationKey = key as LunchChoice;
const locationChoices = data.choices[locationKey];
if (locationChoices && auth.login in locationChoices) {
foundKey = locationKey;
foundChoice = locationChoices[auth.login];
break;
}
}
if (foundKey && choiceRef.current) {
choiceRef.current.value = foundKey;
const restaurantKey = Object.keys(Restaurant).indexOf(foundKey);
if (restaurantKey > -1 && food) {
const restaurant = Object.keys(Restaurant)[restaurantKey] as Restaurant;
setFoodChoiceList(food[restaurant]?.food);
setClosed(food[restaurant]?.closed ?? false);
}
}
if (foundChoice?.departureTime && departureChoiceRef.current) {
departureChoiceRef.current.value = foundChoice.departureTime;
}
}, [auth, auth?.login, data?.choices]) }, [auth, auth?.login, data?.choices])
// Reference na mojí objednávku // Reference na mojí objednávku
@@ -412,11 +388,6 @@ function App() {
{getLunchChoiceName(location)} {getLunchChoiceName(location)}
</h3> </h3>
{menu?.lastUpdate && <small>Aktualizace: {getHumanDateTime(new Date(menu.lastUpdate))}</small>} {menu?.lastUpdate && <small>Aktualizace: {getHumanDateTime(new Date(menu.lastUpdate))}</small>}
{menu?.warnings && menu.warnings.length > 0 && (
<span className="restaurant-warning" title={menu.warnings.join('\n')}>
<FontAwesomeIcon icon={faTriangleExclamation} />
</span>
)}
</div> </div>
{content} {content}
</div> </div>
@@ -461,12 +432,7 @@ function App() {
{easterEgg && eggImage && <img ref={eggRef} alt='' src={URL.createObjectURL(eggImage)} style={{ position: 'absolute', ...EASTER_EGG_STYLE, ...style, animationDuration: `${duration ?? EASTER_EGG_DEFAULT_DURATION}s` }} />} {easterEgg && eggImage && <img ref={eggRef} alt='' src={URL.createObjectURL(eggImage)} style={{ position: 'absolute', ...EASTER_EGG_STYLE, ...style, animationDuration: `${duration ?? EASTER_EGG_DEFAULT_DURATION}s` }} />}
<Header /> <Header />
<div className='wrapper'> <div className='wrapper'>
{data.todayDayIndex != null && data.todayDayIndex > 4 && {data.isWeekend ? <h4>Užívejte víkend :)</h4> : <>
<Alert variant="info" className="mb-3">
Zobrazujete uplynulý týden
</Alert>
}
<>
{dayIndex != null && {dayIndex != null &&
<div className='day-navigator'> <div className='day-navigator'>
<span title='Předchozí den'> <span title='Předchozí den'>
@@ -735,33 +701,7 @@ function App() {
</div> </div>
} }
</div> </div>
{data.pendingQrs && data.pendingQrs.length > 0 && </> || "Jejda, něco se nám nepovedlo :("}
<div className='pizza-section fade-in mt-4'>
<h3>Nevyřízené platby</h3>
<p>Máte neuhrazené QR kódy z předchozích Pizza day.</p>
{data.pendingQrs.map(qr => (
<div key={qr.date} className='qr-code mb-3'>
<p>
<strong>{qr.date}</strong> {qr.creator} ({qr.totalPrice} )
</p>
<img src={`/api/qr?login=${auth.login}`} alt='QR kód' />
<div className='mt-2'>
<Button variant="success" onClick={async () => {
await dismissQr({ body: { date: qr.date } });
// Přenačteme data pro aktualizaci
const response = await getData({ query: { dayIndex } });
if (response.data) {
setData(response.data);
}
}}>
Zaplatil jsem
</Button>
</div>
</div>
))}
</div>
}
</>
</div> </div>
{/* <FallingLeaves {/* <FallingLeaves
numLeaves={LEAF_PRESETS.NORMAL} numLeaves={LEAF_PRESETS.NORMAL}

View File

@@ -1,8 +1,6 @@
import { useEffect, useRef, useState } from "react"; import { useRef } from "react";
import { Modal, Button, Form } from "react-bootstrap" import { Modal, Button, Form } from "react-bootstrap"
import { useSettings, ThemePreference } from "../../context/settings"; import { useSettings, ThemePreference } from "../../context/settings";
import { NotificationSettings, UdalostEnum, getNotificationSettings, updateNotificationSettings } from "../../../../types";
import { useAuth } from "../../context/auth";
type Props = { type Props = {
isOpen: boolean, isOpen: boolean,
@@ -12,56 +10,12 @@ type Props = {
/** Modální dialog pro uživatelská nastavení. */ /** Modální dialog pro uživatelská nastavení. */
export default function SettingsModal({ isOpen, onClose, onSave }: Readonly<Props>) { export default function SettingsModal({ isOpen, onClose, onSave }: Readonly<Props>) {
const auth = useAuth();
const settings = useSettings(); const settings = useSettings();
const bankAccountRef = useRef<HTMLInputElement>(null); const bankAccountRef = useRef<HTMLInputElement>(null);
const nameRef = useRef<HTMLInputElement>(null); const nameRef = useRef<HTMLInputElement>(null);
const hideSoupsRef = useRef<HTMLInputElement>(null); const hideSoupsRef = useRef<HTMLInputElement>(null);
const themeRef = useRef<HTMLSelectElement>(null); const themeRef = useRef<HTMLSelectElement>(null);
const ntfyTopicRef = useRef<HTMLInputElement>(null);
const discordWebhookRef = useRef<HTMLInputElement>(null);
const teamsWebhookRef = useRef<HTMLInputElement>(null);
const [notifSettings, setNotifSettings] = useState<NotificationSettings>({});
const [enabledEvents, setEnabledEvents] = useState<UdalostEnum[]>([]);
useEffect(() => {
if (isOpen && auth?.login) {
getNotificationSettings().then(response => {
if (response.data) {
setNotifSettings(response.data);
setEnabledEvents(response.data.enabledEvents ?? []);
}
}).catch(() => {});
}
}, [isOpen, auth?.login]);
const toggleEvent = (event: UdalostEnum) => {
setEnabledEvents(prev =>
prev.includes(event) ? prev.filter(e => e !== event) : [...prev, event]
);
};
const handleSave = async () => {
// Uložení notifikačních nastavení na server
await updateNotificationSettings({
body: {
ntfyTopic: ntfyTopicRef.current?.value || undefined,
discordWebhookUrl: discordWebhookRef.current?.value || undefined,
teamsWebhookUrl: teamsWebhookRef.current?.value || undefined,
enabledEvents,
}
}).catch(() => {});
// Uložení ostatních nastavení (localStorage)
onSave(
bankAccountRef.current?.value,
nameRef.current?.value,
hideSoupsRef.current?.checked,
themeRef.current?.value as ThemePreference,
);
};
return ( return (
<Modal show={isOpen} onHide={onClose} size="lg"> <Modal show={isOpen} onHide={onClose} size="lg">
<Modal.Header closeButton> <Modal.Header closeButton>
@@ -97,75 +51,6 @@ export default function SettingsModal({ isOpen, onClose, onSave }: Readonly<Prop
<hr /> <hr />
<h4>Notifikace</h4>
<p>
Nastavením notifikací budete dostávat upozornění o událostech (např. "Jdeme na oběd") přímo do vámi zvoleného komunikačního kanálu.
</p>
<Form.Group className="mb-3">
<Form.Label>ntfy téma (topic)</Form.Label>
<Form.Control
ref={ntfyTopicRef}
type="text"
placeholder="moje-tema"
defaultValue={notifSettings.ntfyTopic}
key={notifSettings.ntfyTopic ?? 'ntfy-empty'}
onKeyDown={e => e.stopPropagation()}
/>
<Form.Text className="text-muted">
Téma pro ntfy push notifikace. Nechte prázdné pro vypnutí.
</Form.Text>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Discord webhook URL</Form.Label>
<Form.Control
ref={discordWebhookRef}
type="text"
placeholder="https://discord.com/api/webhooks/..."
defaultValue={notifSettings.discordWebhookUrl}
key={notifSettings.discordWebhookUrl ?? 'discord-empty'}
onKeyDown={e => e.stopPropagation()}
/>
<Form.Text className="text-muted">
URL webhooku Discord kanálu. Nechte prázdné pro vypnutí.
</Form.Text>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>MS Teams webhook URL</Form.Label>
<Form.Control
ref={teamsWebhookRef}
type="text"
placeholder="https://outlook.office.com/webhook/..."
defaultValue={notifSettings.teamsWebhookUrl}
key={notifSettings.teamsWebhookUrl ?? 'teams-empty'}
onKeyDown={e => e.stopPropagation()}
/>
<Form.Text className="text-muted">
URL webhooku MS Teams kanálu. Nechte prázdné pro vypnutí.
</Form.Text>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Události k odběru</Form.Label>
{Object.values(UdalostEnum).map(event => (
<Form.Check
key={event}
id={`notif-event-${event}`}
type="checkbox"
label={event}
checked={enabledEvents.includes(event)}
onChange={() => toggleEvent(event)}
/>
))}
<Form.Text className="text-muted">
Zvolte události, o kterých chcete být notifikováni. Notifikace jsou odesílány pouze uživatelům se stejnou zvolenou lokalitou.
</Form.Text>
</Form.Group>
<hr />
<h4>Bankovní účet</h4> <h4>Bankovní účet</h4>
<p> <p>
Nastavením čísla účtu umožníte automatické generování QR kódů pro úhradu za vámi provedené objednávky v rámci Pizza day. Nastavením čísla účtu umožníte automatické generování QR kódů pro úhradu za vámi provedené objednávky v rámci Pizza day.
@@ -203,7 +88,7 @@ export default function SettingsModal({ isOpen, onClose, onSave }: Readonly<Prop
<Button variant="secondary" onClick={onClose}> <Button variant="secondary" onClick={onClose}>
Storno Storno
</Button> </Button>
<Button onClick={handleSave}> <Button onClick={() => onSave(bankAccountRef.current?.value, nameRef.current?.value, hideSoupsRef.current?.checked, themeRef.current?.value as ThemePreference)}>
Uložit Uložit
</Button> </Button>
</Modal.Footer> </Modal.Footer>

View File

@@ -89,67 +89,4 @@
.recharts-cartesian-grid-vertical line { .recharts-cartesian-grid-vertical line {
stroke: var(--luncher-border); 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;
}
}
}
} }

View File

@@ -1,10 +1,10 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import Footer from "../components/Footer"; import Footer from "../components/Footer";
import Header from "../components/Header"; import Header from "../components/Header";
import { useAuth } from "../context/auth"; import { useAuth } from "../context/auth";
import Login from "../Login"; import Login from "../Login";
import { formatDate, getFirstWorkDayOfWeek, getHumanDate, getLastWorkDayOfWeek } from "../Utils"; 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 Loader from "../components/Loader";
import { faChevronLeft, faChevronRight, faGear } from "@fortawesome/free-solid-svg-icons"; import { faChevronLeft, faChevronRight, faGear } from "@fortawesome/free-solid-svg-icons";
import { Legend, Line, LineChart, Tooltip, XAxis, YAxis } from "recharts"; import { Legend, Line, LineChart, Tooltip, XAxis, YAxis } from "recharts";
@@ -32,7 +32,6 @@ export default function StatsPage() {
const auth = useAuth(); const auth = useAuth();
const [dateRange, setDateRange] = useState<Date[]>(); const [dateRange, setDateRange] = useState<Date[]>();
const [data, setData] = useState<WeeklyStats>(); const [data, setData] = useState<WeeklyStats>();
const [votingStats, setVotingStats] = useState<VotingStats>();
// Prvotní nastavení aktuálního týdne // Prvotní nastavení aktuálního týdne
useEffect(() => { useEffect(() => {
@@ -49,19 +48,6 @@ export default function StatsPage() {
} }
}, [dateRange]); }, [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 renderLine = (location: LunchChoice) => {
const index = Object.values(LunchChoice).indexOf(location); 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} /> return <Line key={location} name={getLunchChoiceName(location)} type="monotone" dataKey={data => data.locations[location] ?? 0} stroke={COLORS[index]} strokeWidth={STROKE_WIDTH} />
@@ -87,20 +73,13 @@ export default function StatsPage() {
} }
} }
const isCurrentOrFutureWeek = useMemo(() => {
if (!dateRange) return true;
const currentWeekEnd = getLastWorkDayOfWeek(new Date());
currentWeekEnd.setHours(23, 59, 59, 999);
return dateRange[1] >= currentWeekEnd;
}, [dateRange]);
const handleKeyDown = useCallback((e: any) => { const handleKeyDown = useCallback((e: any) => {
if (e.keyCode === 37) { if (e.keyCode === 37) {
handlePreviousWeek(); handlePreviousWeek();
} else if (e.keyCode === 39 && !isCurrentOrFutureWeek) { } else if (e.keyCode === 39) {
handleNextWeek() handleNextWeek()
} }
}, [dateRange, isCurrentOrFutureWeek]); }, [dateRange]);
useEffect(() => { useEffect(() => {
document.addEventListener('keydown', handleKeyDown); document.addEventListener('keydown', handleKeyDown);
@@ -132,7 +111,7 @@ export default function StatsPage() {
</span> </span>
<h2 className="date-range">{getHumanDate(dateRange[0])} - {getHumanDate(dateRange[1])}</h2> <h2 className="date-range">{getHumanDate(dateRange[0])} - {getHumanDate(dateRange[1])}</h2>
<span title="Následující týden"> <span title="Následující týden">
<FontAwesomeIcon icon={faChevronRight} style={{ cursor: "pointer", visibility: isCurrentOrFutureWeek ? "hidden" : "visible" }} onClick={handleNextWeek} /> <FontAwesomeIcon icon={faChevronRight} style={{ cursor: "pointer" }} onClick={handleNextWeek} />
</span> </span>
</div> </div>
<LineChart width={CHART_WIDTH} height={CHART_HEIGHT} data={data}> <LineChart width={CHART_WIDTH} height={CHART_HEIGHT} data={data}>
@@ -142,27 +121,6 @@ export default function StatsPage() {
<Tooltip /> <Tooltip />
<Legend /> <Legend />
</LineChart> </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> </div>
<Footer /> <Footer />
</> </>

View File

@@ -1,20 +1,18 @@
import express from "express"; import express from "express";
import bodyParser from "body-parser"; import bodyParser from "body-parser";
import cors from 'cors'; import cors from 'cors';
import { getData, getDateForWeekIndex, getToday } from "./service"; import { getData, getDateForWeekIndex } from "./service";
import dotenv from 'dotenv'; import dotenv from 'dotenv';
import path from 'path'; import path from 'path';
import { getQr } from "./qr"; import { getQr } from "./qr";
import { generateToken, getLogin, verify } from "./auth"; import { generateToken, verify } from "./auth";
import { getIsWeekend, InsufficientPermissions, parseToken } from "./utils"; import { InsufficientPermissions } from "./utils";
import { getPendingQrs } from "./pizza";
import { initWebsocket } from "./websocket"; import { initWebsocket } from "./websocket";
import pizzaDayRoutes from "./routes/pizzaDayRoutes"; import pizzaDayRoutes from "./routes/pizzaDayRoutes";
import foodRoutes, { refreshMetoda } from "./routes/foodRoutes"; import foodRoutes, { refreshMetoda } from "./routes/foodRoutes";
import votingRoutes from "./routes/votingRoutes"; import votingRoutes from "./routes/votingRoutes";
import easterEggRoutes from "./routes/easterEggRoutes"; import easterEggRoutes from "./routes/easterEggRoutes";
import statsRoutes from "./routes/statsRoutes"; import statsRoutes from "./routes/statsRoutes";
import notificationRoutes from "./routes/notificationRoutes";
const ENVIRONMENT = process.env.NODE_ENV ?? 'production'; const ENVIRONMENT = process.env.NODE_ENV ?? 'production';
dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) }); dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) });
@@ -135,22 +133,8 @@ app.get("/api/data", async (req, res) => {
if (!isNaN(index)) { if (!isNaN(index)) {
date = getDateForWeekIndex(parseInt(req.query.dayIndex)); date = getDateForWeekIndex(parseInt(req.query.dayIndex));
} }
} else if (getIsWeekend(getToday())) {
// Na víkendu zobrazíme pátek místo hlášky "Užívejte víkend"
date = getDateForWeekIndex(4);
} }
const data = await getData(date); res.status(200).json(await getData(date));
// Připojíme nevyřízené QR kódy pro přihlášeného uživatele
try {
const login = getLogin(parseToken(req));
const pendingQrs = await getPendingQrs(login);
if (pendingQrs.length > 0) {
data.pendingQrs = pendingQrs;
}
} catch {
// Token nemusí být validní, ignorujeme
}
res.status(200).json(data);
}); });
// Ostatní routes // Ostatní routes
@@ -159,7 +143,6 @@ app.use("/api/food", foodRoutes);
app.use("/api/voting", votingRoutes); app.use("/api/voting", votingRoutes);
app.use("/api/easterEggs", easterEggRoutes); app.use("/api/easterEggs", easterEggRoutes);
app.use("/api/stats", statsRoutes); app.use("/api/stats", statsRoutes);
app.use("/api/notifications", notificationRoutes);
app.use('/stats', express.static('public')); app.use('/stats', express.static('public'));
app.use(express.static('public')); app.use(express.static('public'));

View File

@@ -3,56 +3,11 @@ import dotenv from 'dotenv';
import path from 'path'; import path from 'path';
import { getClientData, getToday } from "./service"; import { getClientData, getToday } from "./service";
import { getUsersByLocation, getHumanTime } from "./utils"; import { getUsersByLocation, getHumanTime } from "./utils";
import { NotifikaceData, NotifikaceInput, NotificationSettings } from '../../types'; import { NotifikaceData, NotifikaceInput } from '../../types';
import getStorage from "./storage";
const ENVIRONMENT = process.env.NODE_ENV ?? 'production'; const ENVIRONMENT = process.env.NODE_ENV ?? 'production';
dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) }); dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) });
const storage = getStorage();
const NOTIFICATION_SETTINGS_PREFIX = 'notif';
/** Vrátí klíč pro uložení notifikačních nastavení uživatele. */
function getNotificationSettingsKey(login: string): string {
return `${NOTIFICATION_SETTINGS_PREFIX}_${login}`;
}
/** Vrátí nastavení notifikací pro daného uživatele. */
export async function getNotificationSettings(login: string): Promise<NotificationSettings> {
return await storage.getData<NotificationSettings>(getNotificationSettingsKey(login)) ?? {};
}
/** Uloží nastavení notifikací pro daného uživatele. */
export async function saveNotificationSettings(login: string, settings: NotificationSettings): Promise<NotificationSettings> {
await storage.setData(getNotificationSettingsKey(login), settings);
return settings;
}
/** Odešle ntfy notifikaci na dané téma. */
async function ntfyCallToTopic(topic: string, message: string) {
const url = process.env.NTFY_HOST;
const username = process.env.NTFY_USERNAME;
const password = process.env.NTFY_PASSWD;
if (!url || !username || !password) {
return;
}
const token = Buffer.from(`${username}:${password}`, 'utf8').toString('base64');
try {
const response = await axios({
url: `${url}/${topic}`,
method: 'POST',
data: message,
headers: {
'Authorization': `Basic ${token}`,
'Tag': 'meat_on_bone'
}
});
console.log(response.data);
} catch (error) {
console.error(`Chyba při odesílání ntfy notifikace na topic ${topic}:`, error);
}
}
export const ntfyCall = async (data: NotifikaceInput) => { export const ntfyCall = async (data: NotifikaceInput) => {
const url = process.env.NTFY_HOST const url = process.env.NTFY_HOST
const username = process.env.NTFY_USERNAME; const username = process.env.NTFY_USERNAME;
@@ -132,58 +87,10 @@ export const teamsCall = async (data: NotifikaceInput) => {
} }
} }
/** Odešle Teams notifikaci na daný webhook URL. */
async function teamsCallToUrl(webhookUrl: string, data: NotifikaceInput) {
const title = data.udalost;
let time = new Date();
time.setTime(time.getTime() + 1000 * 60);
const message = 'Odcházíme v ' + getHumanTime(time) + ', ' + data.user;
const card = {
'@type': 'MessageCard',
'@context': 'http://schema.org/extensions',
'themeColor': "0072C6",
summary: 'Summary description',
sections: [
{
activityTitle: title,
text: message,
},
],
};
try {
await axios.post(webhookUrl, card, {
headers: {
'content-type': 'application/vnd.microsoft.teams.card.o365connector'
},
});
} catch (error) {
console.error(`Chyba při odesílání Teams notifikace:`, error);
}
}
/** Odešle Discord notifikaci na daný webhook URL. */
async function discordCall(webhookUrl: string, data: NotifikaceInput) {
let time = new Date();
time.setTime(time.getTime() + 1000 * 60);
const message = `🍖 **${data.udalost}** — ${data.user} (odchod v ${getHumanTime(time)})`;
try {
await axios.post(webhookUrl, {
content: message,
}, {
headers: {
'Content-Type': 'application/json',
},
});
} catch (error) {
console.error(`Chyba při odesílání Discord notifikace:`, error);
}
}
/** Zavolá notifikace na všechny konfigurované způsoby notifikace, přetížení proměných na false pro jednotlivé způsoby je vypne*/ /** Zavolá notifikace na všechny konfigurované způsoby notifikace, přetížení proměných na false pro jednotlivé způsoby je vypne*/
export const callNotifikace = async ({ input, teams = true, gotify = false, ntfy = true }: NotifikaceData) => { export const callNotifikace = async ({ input, teams = true, gotify = false, ntfy = true }: NotifikaceData) => {
const notifications: Promise<any>[] = []; const notifications = [];
// Globální notifikace (zpětně kompatibilní)
if (ntfy) { if (ntfy) {
const ntfyPromises = await ntfyCall(input); const ntfyPromises = await ntfyCall(input);
if (ntfyPromises) { if (ntfyPromises) {
@@ -193,33 +100,20 @@ export const callNotifikace = async ({ input, teams = true, gotify = false, ntfy
if (teams) { if (teams) {
const teamsPromises = await teamsCall(input); const teamsPromises = await teamsCall(input);
if (teamsPromises) { if (teamsPromises) {
notifications.push(Promise.resolve(teamsPromises)); notifications.push(teamsPromises);
}
}
// Per-user notifikace: najdeme uživatele se stejnou lokací a odešleme dle jejich nastavení
const clientData = await getClientData(getToday());
const usersToNotify = getUsersByLocation(clientData.choices, input.user);
for (const user of usersToNotify) {
if (user === input.user) continue; // Neposíláme notifikaci spouštějícímu uživateli
const userSettings = await getNotificationSettings(user);
if (!userSettings.enabledEvents?.includes(input.udalost)) continue;
if (userSettings.ntfyTopic) {
notifications.push(ntfyCallToTopic(userSettings.ntfyTopic, `${input.udalost} - spustil: ${input.user}`));
}
if (userSettings.discordWebhookUrl) {
notifications.push(discordCall(userSettings.discordWebhookUrl, input));
}
if (userSettings.teamsWebhookUrl) {
notifications.push(teamsCallToUrl(userSettings.teamsWebhookUrl, input));
} }
} }
// gotify bych řekl, že už je deprecated
// if (gotify) {
// const gotifyPromises = await gotifyCall(input, gotifyData);
// notifications.push(...gotifyPromises);
// }
try { try {
const results = await Promise.all(notifications); const results = await Promise.all(notifications);
return results; return results;
} catch (error) { } catch (error) {
console.error("Error in callNotifikace: ", error); console.error("Error in callNotifikace: ", error);
// Handle the error as needed
} }
}; };

View File

@@ -4,10 +4,9 @@ import { generateQr } from "./qr";
import getStorage from "./storage"; import getStorage from "./storage";
import { downloadPizzy } from "./chefie"; import { downloadPizzy } from "./chefie";
import { getClientData, getToday, initIfNeeded } from "./service"; import { getClientData, getToday, initIfNeeded } from "./service";
import { Pizza, ClientData, PizzaDayState, PizzaSize, PizzaOrder, PizzaVariant, UdalostEnum, PendingQr } from "../../types/gen/types.gen"; import { Pizza, ClientData, PizzaDayState, PizzaSize, PizzaOrder, PizzaVariant, UdalostEnum } from "../../types/gen/types.gen";
const storage = getStorage(); const storage = getStorage();
const PENDING_QR_PREFIX = 'pending_qr';
/** /**
* Vrátí seznam dostupných pizz pro dnešní den. * Vrátí seznam dostupných pizz pro dnešní den.
@@ -242,12 +241,6 @@ export async function finishPizzaDelivery(login: string, bankAccount?: string, b
let message = order.pizzaList!.map(pizza => `Pizza ${pizza.name} (${pizza.size})`).join(', '); let message = order.pizzaList!.map(pizza => `Pizza ${pizza.name} (${pizza.size})`).join(', ');
await generateQr(order.customer, bankAccount, bankAccountHolder, order.totalPrice, message); await generateQr(order.customer, bankAccount, bankAccountHolder, order.totalPrice, message);
order.hasQr = true; order.hasQr = true;
// Uložíme nevyřízený QR kód pro persistentní zobrazení
await addPendingQr(order.customer, {
date: today,
creator: login,
totalPrice: order.totalPrice,
});
} }
} }
} }
@@ -314,41 +307,4 @@ export async function updatePizzaFee(login: string, targetLogin: string, text?:
targetOrder.totalPrice = targetOrder.pizzaList.reduce((price, pizzaOrder) => price + pizzaOrder.price, 0) + (targetOrder.fee?.price ?? 0); targetOrder.totalPrice = targetOrder.pizzaList.reduce((price, pizzaOrder) => price + pizzaOrder.price, 0) + (targetOrder.fee?.price ?? 0);
await storage.setData(today, clientData); await storage.setData(today, clientData);
return clientData; return clientData;
}
/**
* Vrátí klíč pro uložení nevyřízených QR kódů uživatele.
*/
function getPendingQrKey(login: string): string {
return `${PENDING_QR_PREFIX}_${login}`;
}
/**
* Přidá nevyřízený QR kód pro uživatele.
*/
async function addPendingQr(login: string, pendingQr: PendingQr): Promise<void> {
const key = getPendingQrKey(login);
const existing = await storage.getData<PendingQr[]>(key) ?? [];
// Nepřidáváme duplicity pro stejný den
if (!existing.some(qr => qr.date === pendingQr.date)) {
existing.push(pendingQr);
await storage.setData(key, existing);
}
}
/**
* Vrátí nevyřízené QR kódy pro uživatele.
*/
export async function getPendingQrs(login: string): Promise<PendingQr[]> {
return await storage.getData<PendingQr[]>(getPendingQrKey(login)) ?? [];
}
/**
* Označí QR kód pro daný den jako uhrazený (odstraní ho ze seznamu nevyřízených).
*/
export async function dismissPendingQr(login: string, date: string): Promise<void> {
const key = getPendingQrKey(login);
const existing = await storage.getData<PendingQr[]>(key) ?? [];
const filtered = existing.filter(qr => qr.date !== date);
await storage.setData(key, filtered);
} }

View File

@@ -1,32 +0,0 @@
import express, { Request } from "express";
import { getLogin } from "../auth";
import { parseToken } from "../utils";
import { getNotificationSettings, saveNotificationSettings } from "../notifikace";
import { UpdateNotificationSettingsData } from "../../../types";
const router = express.Router();
/** Vrátí nastavení notifikací pro přihlášeného uživatele. */
router.get("/settings", async (req, res, next) => {
const login = getLogin(parseToken(req));
try {
const settings = await getNotificationSettings(login);
res.status(200).json(settings);
} catch (e: any) { next(e) }
});
/** Uloží nastavení notifikací pro přihlášeného uživatele. */
router.post("/settings", async (req: Request<{}, any, UpdateNotificationSettingsData["body"]>, res, next) => {
const login = getLogin(parseToken(req));
try {
const settings = await saveNotificationSettings(login, {
ntfyTopic: req.body.ntfyTopic,
discordWebhookUrl: req.body.discordWebhookUrl,
teamsWebhookUrl: req.body.teamsWebhookUrl,
enabledEvents: req.body.enabledEvents,
});
res.status(200).json(settings);
} catch (e: any) { next(e) }
});
export default router;

View File

@@ -1,9 +1,9 @@
import express, { Request } from "express"; import express, { Request } from "express";
import { getLogin } from "../auth"; import { getLogin } from "../auth";
import { createPizzaDay, deletePizzaDay, getPizzaList, addPizzaOrder, removePizzaOrder, lockPizzaDay, unlockPizzaDay, finishPizzaOrder, finishPizzaDelivery, updatePizzaDayNote, updatePizzaFee, dismissPendingQr } from "../pizza"; import { createPizzaDay, deletePizzaDay, getPizzaList, addPizzaOrder, removePizzaOrder, lockPizzaDay, unlockPizzaDay, finishPizzaOrder, finishPizzaDelivery, updatePizzaDayNote, updatePizzaFee } from "../pizza";
import { parseToken } from "../utils"; import { parseToken } from "../utils";
import { getWebsocket } from "../websocket"; import { getWebsocket } from "../websocket";
import { AddPizzaData, DismissQrData, FinishDeliveryData, RemovePizzaData, UpdatePizzaDayNoteData, UpdatePizzaFeeData } from "../../../types"; import { AddPizzaData, FinishDeliveryData, RemovePizzaData, UpdatePizzaDayNoteData, UpdatePizzaFeeData } from "../../../types";
const router = express.Router(); const router = express.Router();
@@ -109,16 +109,4 @@ router.post("/updatePizzaFee", async (req: Request<{}, any, UpdatePizzaFeeData["
} catch (e: any) { next(e) } } catch (e: any) { next(e) }
}); });
/** Označí QR kód jako uhrazený. */
router.post("/dismissQr", async (req: Request<{}, any, DismissQrData["body"]>, res, next) => {
const login = getLogin(parseToken(req));
if (!req.body.date) {
return res.status(400).json({ error: "Nebyl předán datum" });
}
try {
await dismissPendingQr(login, req.body.date);
res.status(200).json({});
} catch (e: any) { next(e) }
});
export default router; export default router;

View File

@@ -1,7 +1,7 @@
import express, { Request } from "express"; import express, { Request } from "express";
import { getLogin } from "../auth"; import { getLogin } from "../auth";
import { parseToken } from "../utils"; import { parseToken } from "../utils";
import { getUserVotes, updateFeatureVote, getVotingStats } from "../voting"; import { getUserVotes, updateFeatureVote } from "../voting";
import { GetVotesData, UpdateVoteData } from "../../../types"; import { GetVotesData, UpdateVoteData } from "../../../types";
const router = express.Router(); const router = express.Router();
@@ -23,11 +23,4 @@ router.post("/updateVote", async (req: Request<{}, any, UpdateVoteData["body"]>,
} catch (e: any) { next(e) } } catch (e: any) { next(e) }
}); });
router.get("/stats", async (req, res, next) => {
try {
const data = await getVotingStats();
res.status(200).json(data);
} catch (e: any) { next(e) }
});
export default router; export default router;

View File

@@ -198,14 +198,7 @@ export async function getRestaurantMenu(restaurant: Restaurant, date?: Date, for
food: [], food: [],
}; };
} }
const MENU_REFETCH_TTL_MS = 60 * 60 * 1000; // 1 hour if (forceRefresh || (!weekMenu[dayOfWeekIndex][restaurant]?.food?.length && !weekMenu[dayOfWeekIndex][restaurant]?.closed)) {
const existingMenu = weekMenu[dayOfWeekIndex][restaurant];
const lastFetchExpired = !existingMenu?.lastUpdate ||
existingMenu.lastUpdate === now || // freshly initialized, never fetched
(now - existingMenu.lastUpdate) > MENU_REFETCH_TTL_MS;
const shouldFetch = forceRefresh ||
(!existingMenu?.food?.length && !existingMenu?.closed && lastFetchExpired);
if (shouldFetch) {
const firstDay = getFirstWorkDayOfWeek(usedDate); const firstDay = getFirstWorkDayOfWeek(usedDate);
try { try {
@@ -247,32 +240,7 @@ export async function getRestaurantMenu(restaurant: Restaurant, date?: Date, for
console.error(`Selhalo načtení jídel pro podnik ${restaurant}`, e); console.error(`Selhalo načtení jídel pro podnik ${restaurant}`, e);
} }
} }
const result = weekMenu[dayOfWeekIndex][restaurant]!; return weekMenu[dayOfWeekIndex][restaurant]!;
result.warnings = generateMenuWarnings(result, now);
return result;
}
/**
* Generuje varování o kvalitě/úplnosti dat menu restaurace.
*/
function generateMenuWarnings(menu: RestaurantDayMenu, now: number): string[] {
const warnings: string[] = [];
if (!menu.food?.length || menu.closed) {
return warnings;
}
const hasSoup = menu.food.some(f => f.isSoup);
if (!hasSoup) {
warnings.push('Chybí polévka');
}
const missingPrice = menu.food.some(f => !f.isSoup && (!f.price || f.price.trim() === ''));
if (missingPrice) {
warnings.push('U některých jídel chybí cena');
}
const STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000;
if (menu.lastUpdate && (now - menu.lastUpdate) > STALE_THRESHOLD_MS) {
warnings.push('Data jsou starší než 24 hodin');
}
return warnings;
} }
/** /**
@@ -410,7 +378,7 @@ export async function addChoice(login: string, trusted: boolean, locationKey: Lu
data = await removeChoiceIfPresent(login, usedDate); data = await removeChoiceIfPresent(login, usedDate);
} else { } else {
// Mažeme případné ostatní volby (měla by být maximálně jedna) // Mažeme případné ostatní volby (měla by být maximálně jedna)
data = await removeChoiceIfPresent(login, usedDate, locationKey); removeChoiceIfPresent(login, usedDate, locationKey);
} }
// TODO vytáhnout inicializaci "prázdné struktury" do vlastní funkce // TODO vytáhnout inicializaci "prázdné struktury" do vlastní funkce
data.choices[locationKey] ??= {}; data.choices[locationKey] ??= {};

View File

@@ -25,12 +25,6 @@ export async function getStats(startDate: string, endDate: string): Promise<Week
throw Error('Neplatný rozsah'); throw Error('Neplatný rozsah');
} }
const today = new Date();
today.setHours(23, 59, 59, 999);
if (end > today) {
throw Error('Nelze načíst statistiky pro budoucí datum');
}
const result = []; const result = [];
for (const date = start; date <= end; date.setDate(date.getDate() + 1)) { for (const date = start; date <= end; date.setDate(date.getDate() + 1)) {
const locationsStats: DailyStats = { const locationsStats: DailyStats = {

View File

@@ -1,14 +1,10 @@
import { FeatureRequest, VotingStats } from "../../types/gen/types.gen"; import { FeatureRequest } from "../../types/gen/types.gen";
import getStorage from "./storage"; import getStorage from "./storage";
interface VotingData { interface VotingData {
[login: string]: FeatureRequest[], [login: string]: FeatureRequest[],
} }
export interface VotingStatsResult {
[feature: string]: number;
}
const storage = getStorage(); const storage = getStorage();
const STORAGE_KEY = 'voting'; const STORAGE_KEY = 'voting';
@@ -55,22 +51,4 @@ export async function updateFeatureVote(login: string, option: FeatureRequest, a
} }
await storage.setData(STORAGE_KEY, data); await storage.setData(STORAGE_KEY, data);
return data; return data;
}
/**
* Vrátí agregované statistiky hlasování - počet hlasů pro každou funkci.
*
* @returns objekt, kde klíčem je název funkce a hodnotou počet hlasů
*/
export async function getVotingStats(): Promise<VotingStatsResult> {
const data = await storage.getData<VotingData>(STORAGE_KEY);
const stats: VotingStatsResult = {};
if (data) {
for (const votes of Object.values(data)) {
for (const feature of votes) {
stats[feature] = (stats[feature] || 0) + 1;
}
}
}
return stats;
} }

View File

@@ -50,12 +50,6 @@ paths:
$ref: "./paths/pizzaDay/updatePizzaDayNote.yml" $ref: "./paths/pizzaDay/updatePizzaDayNote.yml"
/pizzaDay/updatePizzaFee: /pizzaDay/updatePizzaFee:
$ref: "./paths/pizzaDay/updatePizzaFee.yml" $ref: "./paths/pizzaDay/updatePizzaFee.yml"
/pizzaDay/dismissQr:
$ref: "./paths/pizzaDay/dismissQr.yml"
# Notifikace (/api/notifications)
/notifications/settings:
$ref: "./paths/notifications/settings.yml"
# Easter eggy (/api/easterEggs) # Easter eggy (/api/easterEggs)
/easterEggs: /easterEggs:
@@ -72,8 +66,6 @@ paths:
$ref: "./paths/voting/getVotes.yml" $ref: "./paths/voting/getVotes.yml"
/voting/updateVote: /voting/updateVote:
$ref: "./paths/voting/updateVote.yml" $ref: "./paths/voting/updateVote.yml"
/voting/stats:
$ref: "./paths/voting/getVotingStats.yml"
components: components:
schemas: schemas:

View File

@@ -1,26 +0,0 @@
get:
operationId: getNotificationSettings
summary: Vrátí nastavení notifikací pro přihlášeného uživatele.
responses:
"200":
description: Nastavení notifikací
content:
application/json:
schema:
$ref: "../../schemas/_index.yml#/NotificationSettings"
post:
operationId: updateNotificationSettings
summary: Uloží nastavení notifikací pro přihlášeného uživatele.
requestBody:
required: true
content:
application/json:
schema:
$ref: "../../schemas/_index.yml#/NotificationSettings"
responses:
"200":
description: Nastavení notifikací bylo uloženo.
content:
application/json:
schema:
$ref: "../../schemas/_index.yml#/NotificationSettings"

View File

@@ -1,17 +0,0 @@
post:
operationId: dismissQr
summary: Označí QR kód pro daný den jako uhrazený (odstraní ho ze seznamu nevyřízených).
requestBody:
required: true
content:
application/json:
schema:
properties:
date:
description: Datum Pizza day, ke kterému se QR kód vztahuje
type: string
required:
- date
responses:
"200":
description: QR kód byl označen jako uhrazený.

View File

@@ -1,9 +0,0 @@
get:
operationId: getVotingStats
summary: Vrátí agregované statistiky hlasování o nových funkcích.
responses:
"200":
content:
application/json:
schema:
$ref: "../../schemas/_index.yml#/VotingStats"

View File

@@ -53,11 +53,6 @@ ClientData:
description: Datum a čas poslední aktualizace pizz description: Datum a čas poslední aktualizace pizz
type: string type: string
format: date-time format: date-time
pendingQrs:
description: Nevyřízené QR kódy pro platbu z předchozích pizza day
type: array
items:
$ref: "#/PendingQr"
# --- OBĚDY --- # --- OBĚDY ---
UserLunchChoice: UserLunchChoice:
@@ -181,11 +176,6 @@ RestaurantDayMenu:
type: array type: array
items: items:
$ref: "#/Food" $ref: "#/Food"
warnings:
description: Seznam varování o kvalitě/úplnosti dat menu
type: array
items:
type: string
RestaurantDayMenuMap: RestaurantDayMenuMap:
description: Objekt, kde klíčem je podnik ((#Restaurant)) a hodnotou denní menu daného podniku ((#RestaurantDayMenu)) description: Objekt, kde klíčem je podnik ((#Restaurant)) a hodnotou denní menu daného podniku ((#RestaurantDayMenu))
type: object type: object
@@ -268,12 +258,6 @@ FeatureRequest:
- UI - UI
- DEVELOPMENT - DEVELOPMENT
VotingStats:
description: Statistiky hlasování - klíčem je název funkce, hodnotou počet hlasů
type: object
additionalProperties:
type: integer
# --- EASTER EGGS --- # --- EASTER EGGS ---
EasterEgg: EasterEgg:
description: Data pro zobrazení easter eggů ssss description: Data pro zobrazení easter eggů ssss
@@ -532,24 +516,6 @@ NotifikaceData:
type: boolean type: boolean
ntfy: ntfy:
type: boolean type: boolean
NotificationSettings:
description: Nastavení notifikací pro konkrétního uživatele
type: object
properties:
ntfyTopic:
description: Téma pro ntfy push notifikace
type: string
discordWebhookUrl:
description: URL webhooku Discord kanálu
type: string
teamsWebhookUrl:
description: URL webhooku MS Teams kanálu
type: string
enabledEvents:
description: Seznam událostí, o kterých chce být uživatel notifikován
type: array
items:
$ref: "#/UdalostEnum"
GotifyServer: GotifyServer:
type: object type: object
required: required:
@@ -562,23 +528,3 @@ GotifyServer:
type: array type: array
items: items:
type: string type: string
# --- NEVYŘÍZENÉ QR KÓDY ---
PendingQr:
description: Nevyřízený QR kód pro platbu z předchozího Pizza day
type: object
additionalProperties: false
required:
- date
- creator
- totalPrice
properties:
date:
description: Datum Pizza day, ke kterému se QR kód vztahuje
type: string
creator:
description: Jméno zakladatele Pizza day (objednávajícího)
type: string
totalPrice:
description: Celková cena objednávky v Kč
type: number