diff --git a/client/package.json b/client/package.json index 9e9bf39..ac5e33b 100644 --- a/client/package.json +++ b/client/package.json @@ -18,6 +18,7 @@ "bootstrap": "^5.3.8", "react": "^19.2.0", "react-bootstrap": "^2.10.10", + "react-datepicker": "^9.1.0", "react-dom": "^19.2.0", "react-jwt": "^1.3.0", "react-modal": "^3.16.3", diff --git a/client/src/App.scss b/client/src/App.scss index b37eebd..ad218a9 100644 --- a/client/src/App.scss +++ b/client/src/App.scss @@ -284,9 +284,97 @@ body { margin-bottom: 16px; gap: 16px; - input[type="date"] { - text-align: center; - font-weight: 600; + // react-datepicker obaluje input do wrapperu – necháme ho zabrat jen potřebnou šířku + .react-datepicker-wrapper { + width: auto; + } + + .order-date-input { + width: 160px; + cursor: pointer; + } +} + +// Zvýraznění dnů, ve kterých existuje alespoň jedna objednávka – tečka pod číslem dne +.react-datepicker__day.luncher-order-day { + position: relative; + font-weight: 700; + + &::after { + content: ""; + position: absolute; + left: 50%; + bottom: 2px; + transform: translateX(-50%); + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--luncher-primary, #0d6efd); + } + + // U vybraného dne (tmavé pozadí) je tečka světlá, aby byla vidět + &.react-datepicker__day--selected::after, + &.react-datepicker__day--keyboard-selected::after { + background: #fff; + } +} + +// Vybraný den používá akcentovou barvu aplikace (v obou režimech), místo výchozí modré +.react-datepicker__day--selected, +.react-datepicker__day--keyboard-selected { + background-color: var(--luncher-primary); + color: #fff; + + &:hover { + background-color: var(--luncher-primary-hover); + } +} + +// Tmavý režim kalendáře (react-datepicker) – navázáno na CSS proměnné motivu +[data-bs-theme="dark"] { + .react-datepicker { + background-color: var(--luncher-bg-card); + border-color: var(--luncher-border); + color: var(--luncher-text); + } + + .react-datepicker__header { + background-color: var(--luncher-bg-hover); + border-bottom-color: var(--luncher-border); + } + + .react-datepicker__current-month, + .react-datepicker__day-name, + .react-datepicker__day, + .react-datepicker-year-header { + color: var(--luncher-text); + } + + .react-datepicker__day:hover, + .react-datepicker__month-text:hover { + background-color: var(--luncher-bg-hover); + } + + .react-datepicker__day--today { + color: var(--luncher-primary); + } + + .react-datepicker__day--disabled, + .react-datepicker__day--outside-month { + color: var(--luncher-text-muted); + } + + // Šipky pro přepínání měsíců + .react-datepicker__navigation-icon::before { + border-color: var(--luncher-text-secondary); + } + + // Špička popoveru (SVG) míří do hlavičky – sladíme barvy. + // !important kvůli vyšší specificitě knihovního pravidla [data-placement]. + .react-datepicker__triangle { + fill: var(--luncher-bg-hover) !important; + color: var(--luncher-bg-hover) !important; + stroke: var(--luncher-border) !important; } } diff --git a/client/src/pages/OrderGroupsPage.tsx b/client/src/pages/OrderGroupsPage.tsx index 0ed6c6e..ca6e01b 100644 --- a/client/src/pages/OrderGroupsPage.tsx +++ b/client/src/pages/OrderGroupsPage.tsx @@ -3,9 +3,12 @@ import { Alert, Badge, Button, Card, Form, Modal, OverlayTrigger, Table, Tooltip import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faTrashCan } from '@fortawesome/free-regular-svg-icons'; import { faBasketShopping, faChevronLeft, faChevronRight, faCircleCheck, faClockRotateLeft, faGear, faLock, faLockOpen, faSearch, faUserPlus } from '@fortawesome/free-solid-svg-icons'; +import DatePicker, { registerLocale } from 'react-datepicker'; +import { cs } from 'date-fns/locale'; +import 'react-datepicker/dist/react-datepicker.css'; import { ClientData, GroupState, MealSlot, OrderGroup, OrderGroupMember, PendingQr, - getData, createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes, + getData, createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes, getOrderDates, } from '../../../types'; import { computeFeeShare, computeMemberTotal, countActiveMembers } from '../utils/groupFees'; import { EVENT_MESSAGE, EVENT_PENDING_QR, SocketContext } from '../context/socket'; @@ -24,6 +27,9 @@ import PendingPayments from '../components/PendingPayments'; const SLOT = MealSlot.EXTRA; const TIME_REGEX = /^([01]\d|2[0-3]):[0-5]\d$/; +// Český lokál pro date picker (názvy měsíců/dnů, pondělí jako první den) +registerLocale('cs', cs); + /** Vrátí ISO datum (YYYY-MM-DD) posunuté o předaný počet dní. */ function shiftIsoDate(iso: string, days: number): string { const date = new Date(`${iso}T00:00:00`); @@ -31,6 +37,11 @@ function shiftIsoDate(iso: string, days: number): string { return formatDate(date); } +/** Převede ISO datum (YYYY-MM-DD) na lokální Date (půlnoc), nebo null. */ +function isoToDate(iso?: string): Date | null { + return iso ? new Date(`${iso}T00:00:00`) : null; +} + function stateBadge(state: GroupState) { const map: Record = { [GroupState.OPEN]: { bg: 'success', label: 'Otevřeno' }, @@ -53,6 +64,8 @@ export default function OrderGroupsPage() { const [todayIso, setTodayIso] = useState(); // Ref pro socket handler – aby věděl, zda se zobrazuje historie (na ní se živé aktualizace neaplikují) const selectedDateRef = useRef(undefined); + // ISO data dnů, ve kterých existuje aspoň jedna objednávka (pro zvýraznění v date pickeru) + const [orderDates, setOrderDates] = useState([]); const [newGroupName, setNewGroupName] = useState(''); const [creating, setCreating] = useState(false); const [adminModalOpen, setAdminModalOpen] = useState(false); @@ -78,6 +91,12 @@ export default function OrderGroupsPage() { } }; + // Načte dny s objednávkou pro zvýraznění v date pickeru + const fetchOrderDates = async () => { + const r = await getOrderDates(); + if (r.data?.dates) setOrderDates(r.data.dates); + }; + useEffect(() => { selectedDateRef.current = selectedDate; }, [selectedDate]); @@ -87,6 +106,11 @@ export default function OrderGroupsPage() { fetchData(selectedDate); }, [auth?.login, selectedDate]); + useEffect(() => { + if (!auth?.login) return; + fetchOrderDates(); + }, [auth?.login]); + useEffect(() => { socket.on(EVENT_MESSAGE, (newData: ClientData) => { // Živé aktualizace se týkají vždy dneška – při zobrazení historie je ignorujeme @@ -139,6 +163,8 @@ export default function OrderGroupsPage() { socket.emit?.('message', result.data as ClientData); } await fetchData(); + // Sada dnů s objednávkou se mohla změnit (vytvoření/smazání skupiny) + fetchOrderDates(); return true; }; @@ -265,6 +291,11 @@ export default function OrderGroupsPage() { setSelectedDate(todayIso != null && value >= todayIso ? undefined : value); }; + // Dny s objednávkou jako Date objekty pro zvýraznění v kalendáři + const highlightedOrderDates = orderDates + .map(d => isoToDate(d)) + .filter((d): d is Date => d != null); + return (
@@ -282,13 +313,16 @@ export default function OrderGroupsPage() { goToDay(-1)} /> - handleDatePick(e.target.value)} - className={isReadOnly ? 'text-muted' : ''} - style={{ maxWidth: 200 }} + handleDatePick(d ? formatDate(d) : '')} + maxDate={isoToDate(todayIso) ?? undefined} + highlightDates={[{ 'luncher-order-day': highlightedOrderDates }]} + locale="cs" + dateFormat="d. M. yyyy" + calendarStartDay={1} + popperPlacement="bottom" + className={`form-control text-center fw-semibold order-date-input ${isReadOnly ? 'text-muted' : ''}`} /> g.id === id); } +/** + * Vrátí seznam ISO dat (YYYY-MM-DD), pro která existuje alespoň jedna objednávková skupina. + * Slouží ke zvýraznění dnů v date pickeru na stránce objednávání. + */ +export async function getOrderDates(): Promise { + const EXTRA_SUFFIX = '_extra'; + const keys = await storage.listKeys(EXTRA_SUFFIX); + const dates: string[] = []; + for (const key of keys) { + if (!key.endsWith(EXTRA_SUFFIX)) continue; + const data = await storage.getData(key); + if (data?.groups && data.groups.length > 0) { + dates.push(key.slice(0, -EXTRA_SUFFIX.length)); + } + } + return dates.sort(); +} + export async function createGroup(creatorLogin: string, name: string, date?: Date): Promise { const stores = await getStores(); if (!stores.some(s => s.toLowerCase() === name.trim().toLowerCase())) { diff --git a/server/src/routes/groupRoutes.ts b/server/src/routes/groupRoutes.ts index 52915e5..d0cec5c 100644 --- a/server/src/routes/groupRoutes.ts +++ b/server/src/routes/groupRoutes.ts @@ -2,7 +2,7 @@ import express, { Request } from "express"; import { getLogin } from "../auth"; import { parseToken } from "../utils"; import { getWebsocket } from "../websocket"; -import { createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes, updateGroupFees } from "../groups"; +import { createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes, updateGroupFees, getOrderDates } from "../groups"; import { GroupState } from "../../../types/gen/types.gen"; const router = express.Router(); @@ -11,6 +11,13 @@ function broadcastExtra(data: any) { getWebsocket().emit("message", data); } +router.get("/dates", async (_req, res, next) => { + try { + const dates = await getOrderDates(); + res.status(200).json({ dates }); + } catch (e: any) { next(e); } +}); + router.post("/create", async (req: Request, res, next) => { const login = getLogin(parseToken(req)); const { name } = req.body ?? {}; diff --git a/server/src/storage/StorageInterface.ts b/server/src/storage/StorageInterface.ts index 8956456..afb5d9a 100644 --- a/server/src/storage/StorageInterface.ts +++ b/server/src/storage/StorageInterface.ts @@ -29,4 +29,10 @@ export interface StorageInterface { * @param data data pro uložení */ setData(key: string, data: Type): Promise; + + /** + * Vrátí seznam všech klíčů, případně jen těch obsahujících předaný podřetězec. + * @param contains volitelný podřetězec, který musí klíč obsahovat (např. '_extra') + */ + listKeys(contains?: string): Promise; } \ No newline at end of file diff --git a/server/src/storage/json.ts b/server/src/storage/json.ts index 30d386b..e100a26 100644 --- a/server/src/storage/json.ts +++ b/server/src/storage/json.ts @@ -29,4 +29,9 @@ export default class JsonStorage implements StorageInterface { db.set(key, data); return Promise.resolve(); } + + listKeys(contains?: string): Promise { + const keys = Object.keys(db.JSON()); + return Promise.resolve(contains ? keys.filter(k => k.includes(contains)) : keys); + } } \ No newline at end of file diff --git a/server/src/storage/memory.ts b/server/src/storage/memory.ts index b75a41f..719768f 100644 --- a/server/src/storage/memory.ts +++ b/server/src/storage/memory.ts @@ -24,4 +24,9 @@ export default class MemoryStorage implements StorageInterface { store.set(key, data); return Promise.resolve(); } + + listKeys(contains?: string): Promise { + const keys = Array.from(store.keys()); + return Promise.resolve(contains ? keys.filter(k => k.includes(contains)) : keys); + } } diff --git a/server/src/storage/redis.ts b/server/src/storage/redis.ts index bd158c7..b08a93c 100644 --- a/server/src/storage/redis.ts +++ b/server/src/storage/redis.ts @@ -31,4 +31,16 @@ export default class RedisStorage implements StorageInterface { await client.json.set(key, '.', data as any); await client.json.get(key); } + + async listKeys(contains?: string): Promise { + // SCAN je bezpečnější než KEYS na produkci (neblokuje server) + const match = contains ? `*${contains}*` : '*'; + const keys: string[] = []; + for await (const key of client.scanIterator({ MATCH: match, COUNT: 100 })) { + // node-redis v4 vrací buď string, nebo (novější verze) pole stringů + if (Array.isArray(key)) keys.push(...key); + else keys.push(key); + } + return keys; + } } \ No newline at end of file diff --git a/server/src/tests/storage-contract.test.ts b/server/src/tests/storage-contract.test.ts index cf8717b..14d4ae3 100644 --- a/server/src/tests/storage-contract.test.ts +++ b/server/src/tests/storage-contract.test.ts @@ -26,6 +26,10 @@ const implementations: [string, () => StorageInterface, () => void][] = [ inst.hasData = async (key: string) => Promise.resolve((inst as any).db.has(key)); inst.getData = async (key: string) => (inst as any).db.get(key); inst.setData = async (key: string, data: any) => { (inst as any).db.set(key, data); return Promise.resolve(); }; + inst.listKeys = async (contains?: string) => { + const keys = Object.keys((inst as any).db.JSON()); + return contains ? keys.filter((k: string) => k.includes(contains)) : keys; + }; return inst; }, () => { if (fs.existsSync(tempDbPath)) { @@ -76,6 +80,22 @@ describe.each(implementations)('%s splňuje StorageInterface kontrakt', (name, f expect((await storage.getData<{ val: string }>('a'))?.val).toBe('A'); expect((await storage.getData<{ val: string }>('b'))?.val).toBe('B'); }); + + test('listKeys vrátí všechny uložené klíče', async () => { + await storage.setData('2024-01-01_extra', {}); + await storage.setData('2024-01-02', {}); + const keys = await storage.listKeys(); + expect(keys).toContain('2024-01-01_extra'); + expect(keys).toContain('2024-01-02'); + }); + + test('listKeys filtruje podle podřetězce', async () => { + await storage.setData('2024-01-01_extra', {}); + await storage.setData('2024-01-02_extra', {}); + await storage.setData('2024-01-02', {}); + const keys = await storage.listKeys('_extra'); + expect(keys.sort()).toEqual(['2024-01-01_extra', '2024-01-02_extra']); + }); }); afterAll(() => { diff --git a/types/api.yml b/types/api.yml index 652c0c5..ee28f36 100644 --- a/types/api.yml +++ b/types/api.yml @@ -82,6 +82,8 @@ paths: $ref: "./paths/changelogs/getChangelogs.yml" # Skupiny objednávek (/api/groups) + /groups/dates: + $ref: "./paths/groups/getOrderDates.yml" /groups/create: $ref: "./paths/groups/createGroup.yml" /groups/delete: diff --git a/types/paths/groups/getOrderDates.yml b/types/paths/groups/getOrderDates.yml new file mode 100644 index 0000000..befde66 --- /dev/null +++ b/types/paths/groups/getOrderDates.yml @@ -0,0 +1,19 @@ +get: + operationId: getOrderDates + summary: Vrátí seznam dnů, pro které existuje alespoň jedna objednávková skupina. + responses: + "200": + description: Seznam dnů s objednávkou + content: + application/json: + schema: + type: object + required: + - dates + properties: + dates: + description: Pole ISO dat (YYYY-MM-DD) s alespoň jednou skupinou + type: array + items: + type: string + format: date