feat: možnost editace existujících podniků u objednávání

This commit is contained in:
2026-08-25 11:43:35 +02:00
parent ce051447e6
commit 6f6567a2a9
7 changed files with 327 additions and 51 deletions
+144 -48
View File
@@ -2,8 +2,8 @@ import { useState } from "react";
import { Modal, Button, Form, ListGroup, Alert } from "react-bootstrap"; import { Modal, Button, Form, ListGroup, Alert } from "react-bootstrap";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faTrashCan } from "@fortawesome/free-regular-svg-icons"; import { faTrashCan } from "@fortawesome/free-regular-svg-icons";
import { faPlus, faUpRightFromSquare, faXmark } from "@fortawesome/free-solid-svg-icons"; import { faPen, faPlus, faUpRightFromSquare, faXmark } from "@fortawesome/free-solid-svg-icons";
import { addStore, deleteStore, Store } from "../../../../types"; import { addStore, deleteStore, updateStore, Store } from "../../../../types";
import { getStoreUrls, storeUrlLabel } from "../../utils/storeUrls"; import { getStoreUrls, storeUrlLabel } from "../../utils/storeUrls";
type Props = { type Props = {
@@ -13,21 +13,47 @@ type Props = {
onStoresChanged: (stores: Store[]) => void; onStoresChanged: (stores: Store[]) => void;
}; };
/** Rozpracovaná editace obchodu — původní název slouží k jeho identifikaci na serveru. */
type EditState = {
originalName: string;
name: string;
urls: string[];
};
/** Vstupní pole pro URL nabídek — vždy alespoň jedno prázdné, aby bylo kam psát. */
function toUrlInputs(urls: string[]): string[] {
return urls.length > 0 ? urls : [''];
}
export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChanged }: Readonly<Props>) { export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChanged }: Readonly<Props>) {
const [newName, setNewName] = useState(''); const [newName, setNewName] = useState('');
// Jeden podnik může být dostupný přes více dovozových služeb — proto seznam URL // Jeden podnik může být dostupný přes více dovozových služeb — proto seznam URL
const [newUrls, setNewUrls] = useState<string[]>(['']); const [newUrls, setNewUrls] = useState<string[]>(['']);
const [edit, setEdit] = useState<EditState | null>(null);
const [heslo, setHeslo] = useState(''); const [heslo, setHeslo] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const setUrlAt = (index: number, value: string) => const addUrlRow = (urls: string[]) => [...urls, ''];
setNewUrls(prev => prev.map((u, i) => (i === index ? value : u)));
const addUrlRow = () => setNewUrls(prev => [...prev, '']); const removeUrlRow = (urls: string[], index: number) =>
urls.length === 1 ? [''] : urls.filter((_, i) => i !== index);
const removeUrlRow = (index: number) => const setUrlAt = (urls: string[], index: number, value: string) =>
setNewUrls(prev => (prev.length === 1 ? [''] : prev.filter((_, i) => i !== index))); urls.map((u, i) => (i === index ? value : u));
/** Zpracuje odpověď API — vrací true při úspěchu. */
const applyResult = (res: { data?: unknown; error?: unknown }): boolean => {
if (res.error) {
setError((res.error as any).error || 'Nastala chyba');
return false;
}
if (res.data) {
onStoresChanged(res.data as Store[]);
return true;
}
return false;
};
const handleAdd = async () => { const handleAdd = async () => {
if (!newName.trim()) return; if (!newName.trim()) return;
@@ -36,10 +62,7 @@ export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChang
try { try {
const urls = newUrls.map(u => u.trim()).filter(Boolean); const urls = newUrls.map(u => u.trim()).filter(Boolean);
const res = await addStore({ body: { name: newName.trim(), urls: urls.length > 0 ? urls : undefined, heslo } }); const res = await addStore({ body: { name: newName.trim(), urls: urls.length > 0 ? urls : undefined, heslo } });
if (res.error) { if (applyResult(res)) {
setError((res.error as any).error || 'Nastala chyba');
} else if (res.data) {
onStoresChanged(res.data as Store[]);
setNewName(''); setNewName('');
setNewUrls(['']); setNewUrls(['']);
} }
@@ -50,15 +73,41 @@ export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChang
} }
}; };
const startEdit = (store: Store) => {
setError(null);
setEdit({ originalName: store.name, name: store.name, urls: toUrlInputs(store.urls ?? []) });
};
const handleSaveEdit = async () => {
if (!edit || !edit.name.trim()) return;
setError(null);
setLoading(true);
try {
const res = await updateStore({
body: {
name: edit.originalName,
newName: edit.name.trim(),
urls: edit.urls.map(u => u.trim()).filter(Boolean),
heslo,
},
});
if (applyResult(res)) {
setEdit(null);
}
} catch (e: any) {
setError(e.message || 'Nastala chyba');
} finally {
setLoading(false);
}
};
const handleRemove = async (name: string) => { const handleRemove = async (name: string) => {
setError(null); setError(null);
setLoading(true); setLoading(true);
try { try {
const res = await deleteStore({ body: { name, heslo } }); const res = await deleteStore({ body: { name, heslo } });
if (res.error) { if (applyResult(res) && edit?.originalName === name) {
setError((res.error as any).error || 'Nastala chyba'); setEdit(null);
} else if (res.data) {
onStoresChanged(res.data as Store[]);
} }
} catch (e: any) { } catch (e: any) {
setError(e.message || 'Nastala chyba'); setError(e.message || 'Nastala chyba');
@@ -67,6 +116,37 @@ export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChang
} }
}; };
/** Řádky se vstupy pro URL — používá se pro přidání i pro editaci obchodu. */
const renderUrlInputs = (urls: string[], onChange: (urls: string[]) => void, onSubmit: () => void) => (
<>
{urls.map((url, index) => (
<div key={index} className="d-flex gap-2 mb-2 align-items-center">
<Form.Control
type="url"
placeholder={index === 0
? 'URL na nabídku (volitelné, např. Bolt Food/Wolt)'
: 'Další URL na nabídku (jiná dovozová služba)'}
value={url}
onChange={e => onChange(setUrlAt(urls, index, e.target.value))}
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') onSubmit(); }}
/>
<Button
variant="outline-secondary"
onClick={() => onChange(removeUrlRow(urls, index))}
disabled={urls.length === 1 && !url}
title="Odebrat tuto URL"
>
<FontAwesomeIcon icon={faXmark} />
</Button>
</div>
))}
<Button variant="link" size="sm" className="p-0" onClick={() => onChange(addUrlRow(urls))}>
<FontAwesomeIcon icon={faPlus} className="me-1" />
Přidat další URL
</Button>
</>
);
return ( return (
<Modal show={isOpen} onHide={onClose}> <Modal show={isOpen} onHide={onClose}>
<Modal.Header closeButton> <Modal.Header closeButton>
@@ -100,32 +180,8 @@ export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChang
onChange={e => setNewName(e.target.value)} onChange={e => setNewName(e.target.value)}
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleAdd(); }} onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleAdd(); }}
/> />
{newUrls.map((url, index) => ( {renderUrlInputs(newUrls, setNewUrls, handleAdd)}
<div key={index} className="d-flex gap-2 mb-2 align-items-center"> <div className="d-flex justify-content-end mb-3">
<Form.Control
type="url"
placeholder={index === 0
? 'URL na nabídku (volitelné, např. Bolt Food/Wolt)'
: 'Další URL na nabídku (jiná dovozová služba)'}
value={url}
onChange={e => setUrlAt(index, e.target.value)}
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleAdd(); }}
/>
<Button
variant="outline-secondary"
onClick={() => removeUrlRow(index)}
disabled={newUrls.length === 1 && !url}
title="Odebrat tuto URL"
>
<FontAwesomeIcon icon={faXmark} />
</Button>
</div>
))}
<div className="d-flex justify-content-between align-items-center mb-3">
<Button variant="link" size="sm" className="p-0" onClick={addUrlRow}>
<FontAwesomeIcon icon={faPlus} className="me-1" />
Přidat další URL
</Button>
<Button variant="primary" onClick={handleAdd} disabled={loading || !newName.trim() || !heslo}> <Button variant="primary" onClick={handleAdd} disabled={loading || !newName.trim() || !heslo}>
Přidat Přidat
</Button> </Button>
@@ -138,6 +194,37 @@ export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChang
<ListGroup> <ListGroup>
{stores.map(s => { {stores.map(s => {
const urls = getStoreUrls(s); const urls = getStoreUrls(s);
const isEditing = edit?.originalName === s.name;
if (isEditing) {
return (
<ListGroup.Item key={s.name}>
<Form.Control
className="mb-2"
type="text"
placeholder="Název obchodu"
value={edit.name}
onChange={e => setEdit({ ...edit, name: e.target.value })}
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveEdit(); }}
/>
{renderUrlInputs(edit.urls, next => setEdit({ ...edit, urls: next }), handleSaveEdit)}
<div className="d-flex justify-content-end gap-2 mt-2">
<Button variant="secondary" size="sm" onClick={() => setEdit(null)}>
Zrušit
</Button>
<Button
variant="primary"
size="sm"
onClick={handleSaveEdit}
disabled={loading || !edit.name.trim() || !heslo}
>
Uložit
</Button>
</div>
</ListGroup.Item>
);
}
return ( return (
<ListGroup.Item key={s.name} className="d-flex justify-content-between align-items-start"> <ListGroup.Item key={s.name} className="d-flex justify-content-between align-items-start">
<div> <div>
@@ -159,13 +246,22 @@ export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChang
</small> </small>
)} )}
</div> </div>
<FontAwesomeIcon <div className="d-flex gap-3">
icon={faTrashCan} <FontAwesomeIcon
className="action-icon" icon={faPen}
title="Odebrat" className="action-icon"
onClick={() => handleRemove(s.name)} title="Upravit"
style={{ cursor: 'pointer' }} onClick={() => startEdit(s)}
/> style={{ cursor: 'pointer' }}
/>
<FontAwesomeIcon
icon={faTrashCan}
className="action-icon"
title="Odebrat"
onClick={() => handleRemove(s.name)}
style={{ cursor: 'pointer' }}
/>
</div>
</ListGroup.Item> </ListGroup.Item>
); );
})} })}
+2 -1
View File
@@ -1,5 +1,6 @@
[ [
"Statistiky se nově načtou i pro aktuální týden, který ještě neskončil", "Statistiky se nově načtou i pro aktuální týden, který ještě neskončil",
"Podnik může mít více odkazů na nabídku (např. Bolt Food, Wolt i Foodora)", "Podnik může mít více odkazů na nabídku (např. Bolt Food, Wolt i Foodora)",
"Při vytváření skupiny lze vybrat, přes kterou službu se bude objednávat — odkaz v záhlaví skupiny pak vede přímo na ni" "Při vytváření skupiny lze vybrat, přes kterou službu se bude objednávat — odkaz v záhlaví skupiny pak vede přímo na ni",
"Existující podnik lze upravit — přejmenovat i změnit odkazy na nabídku, bez nutnosti smazat a znovu přidat"
] ]
+26 -1
View File
@@ -1,5 +1,5 @@
import express from "express"; import express from "express";
import { getStores, addStore, removeStore } from "../stores"; import { getStores, addStore, updateStore, removeStore } from "../stores";
const router = express.Router(); const router = express.Router();
@@ -32,6 +32,31 @@ router.post("/add", async (req, res, next) => {
} }
}); });
router.post("/update", async (req, res, next) => {
const { name, newName, heslo, urls } = req.body ?? {};
if (!name || typeof name !== 'string') {
return res.status(400).json({ error: 'Nebyl předán název obchodu' });
}
if (!heslo || typeof heslo !== 'string') {
return res.status(400).json({ error: 'Nebylo předáno heslo' });
}
if (newName != null && typeof newName !== 'string') {
return res.status(400).json({ error: 'Neplatný název obchodu' });
}
if (urls != null && (!Array.isArray(urls) || urls.some((u: unknown) => typeof u !== 'string'))) {
return res.status(400).json({ error: 'Neplatný seznam URL obchodu' });
}
try {
const stores = await updateStore(name, heslo, newName ?? undefined, urls ?? undefined);
res.status(200).json(stores);
} catch (e: any) {
if (e.message === 'UNAUTHORIZED') {
return res.status(403).json({ error: 'Nesprávné heslo' });
}
next(e);
}
});
router.post("/delete", async (req, res, next) => { router.post("/delete", async (req, res, next) => {
const { name, heslo } = req.body ?? {}; const { name, heslo } = req.body ?? {};
if (!name || typeof name !== 'string') { if (!name || typeof name !== 'string') {
+40
View File
@@ -95,6 +95,46 @@ export async function addStore(name: string, heslo: string, urls?: string[]): Pr
return updated; return updated;
} }
/**
* Upraví existující obchod — název a/nebo seznam URL na nabídku.
*
* @param name aktuální název obchodu (identifikuje upravovaný obchod)
* @param heslo admin heslo
* @param newName nový název; pokud není předán, název zůstane nezměněn
* @param urls nový seznam URL (nahradí stávající); pokud není předán, URL zůstanou nezměněné
*/
export async function updateStore(name: string, heslo: string, newName?: string, urls?: string[]): Promise<Store[]> {
const adminPassword = process.env.ADMIN_PASSWORD;
if (!adminPassword || heslo !== adminPassword) {
throw new Error('UNAUTHORIZED');
}
const stores = await getStores();
const index = stores.findIndex(s => s.name.toLowerCase() === name.trim().toLowerCase());
if (index < 0) {
throw new Error('Obchod nebyl nalezen');
}
const current = stores[index];
let finalName = current.name;
if (newName !== undefined) {
const trimmedName = newName.trim();
if (!trimmedName) {
throw new Error('Název obchodu nesmí být prázdný');
}
// Nový název nesmí kolidovat s jiným obchodem (shoda se sebou samým je v pořádku)
if (stores.some((s, i) => i !== index && s.name.toLowerCase() === trimmedName.toLowerCase())) {
throw new Error('Obchod s tímto názvem již existuje');
}
finalName = trimmedName;
}
const finalUrls = urls !== undefined ? normalizeUrls(urls) : (current.urls ?? []);
const updatedStore: Store = finalUrls.length > 0 ? { name: finalName, urls: finalUrls } : { name: finalName };
const updated = stores.map((s, i) => (i === index ? updatedStore : s));
await storage.setData(STORES_KEY, updated);
return updated;
}
/** /**
* Odebere obchod ze seznamu povolených (dle názvu). * Odebere obchod ze seznamu povolených (dle názvu).
* *
+77 -1
View File
@@ -1,6 +1,6 @@
import { resetMemoryStorage } from '../storage/memory'; import { resetMemoryStorage } from '../storage/memory';
import getStorage from '../storage'; import getStorage from '../storage';
import { getStores, addStore, removeStore } from '../stores'; import { getStores, addStore, updateStore, removeStore } from '../stores';
const ADMIN_PW = 'testadmin'; const ADMIN_PW = 'testadmin';
@@ -113,6 +113,82 @@ describe('addStore', () => {
}); });
}); });
describe('updateStore', () => {
const WOLT_URL = 'https://wolt.com/bistro';
const BOLT_URL = 'https://food.bolt.eu/bistro';
beforeEach(async () => {
await addStore('Bistro', ADMIN_PW, [WOLT_URL]);
});
test('přidá další URL k existujícímu obchodu', async () => {
const stores = await updateStore('Bistro', ADMIN_PW, undefined, [WOLT_URL, BOLT_URL]);
expect(stores).toContainEqual({ name: 'Bistro', urls: [WOLT_URL, BOLT_URL] });
});
test('přejmenuje obchod a zachová URL', async () => {
const stores = await updateStore('Bistro', ADMIN_PW, ' Bistro U Nás ');
expect(stores).toContainEqual({ name: 'Bistro U Nás', urls: [WOLT_URL] });
});
test('prázdné pole URL odstraní všechny URL', async () => {
const stores = await updateStore('Bistro', ADMIN_PW, undefined, []);
expect(stores).toContainEqual({ name: 'Bistro' });
});
test('bez předaných URL zůstanou URL nezměněné', async () => {
const stores = await updateStore('Bistro', ADMIN_PW, 'Bistro 2');
expect(stores).toContainEqual({ name: 'Bistro 2', urls: [WOLT_URL] });
});
test('zachová pozici obchodu v seznamu', async () => {
await addStore('KFC', ADMIN_PW);
const stores = await updateStore('Bistro', ADMIN_PW, 'Bistro 2');
expect(names(stores)).toEqual(['Bistro 2', 'KFC']);
});
test('funguje case-insensitive dle názvu', async () => {
const stores = await updateStore('BISTRO', ADMIN_PW, undefined, [BOLT_URL]);
expect(stores).toContainEqual({ name: 'Bistro', urls: [BOLT_URL] });
});
test('ponechání stejného názvu není bráno jako duplikát', async () => {
const stores = await updateStore('Bistro', ADMIN_PW, 'bistro');
expect(names(stores)).toEqual(['bistro']);
});
test('odmítne název kolidující s jiným obchodem', async () => {
await addStore('KFC', ADMIN_PW);
await expect(updateStore('Bistro', ADMIN_PW, 'kfc')).rejects.toThrow('existuje');
});
test('odmítne prázdný název', async () => {
await expect(updateStore('Bistro', ADMIN_PW, ' ')).rejects.toThrow('prázdný');
});
test('odmítne nevalidní URL', async () => {
await expect(updateStore('Bistro', ADMIN_PW, undefined, ['javascript:alert(1)'])).rejects.toThrow('http');
});
test('vyhodí chybu u neexistujícího obchodu', async () => {
await expect(updateStore('Neexistuje', ADMIN_PW, 'Nový')).rejects.toThrow('nebyl nalezen');
});
test('vyhodí UNAUTHORIZED s nesprávným heslem', async () => {
await expect(updateStore('Bistro', 'spatne', 'Hacknuto')).rejects.toThrow('UNAUTHORIZED');
});
test('vyhodí UNAUTHORIZED pokud ADMIN_PASSWORD není nastaven', async () => {
delete process.env.ADMIN_PASSWORD;
await expect(updateStore('Bistro', '', 'Hacknuto')).rejects.toThrow('UNAUTHORIZED');
});
test('při nesprávném heslu se obchod nezmění', async () => {
await expect(updateStore('Bistro', 'spatne', 'Hacknuto')).rejects.toThrow('UNAUTHORIZED');
expect(names(await getStores())).toEqual(['Bistro']);
});
});
describe('removeStore', () => { describe('removeStore', () => {
beforeEach(async () => { beforeEach(async () => {
await addStore('McDonald\'s', ADMIN_PW); await addStore('McDonald\'s', ADMIN_PW);
+2
View File
@@ -152,6 +152,8 @@ paths:
$ref: "./paths/stores/listStores.yml" $ref: "./paths/stores/listStores.yml"
/stores/add: /stores/add:
$ref: "./paths/stores/addStore.yml" $ref: "./paths/stores/addStore.yml"
/stores/update:
$ref: "./paths/stores/updateStore.yml"
/stores/delete: /stores/delete:
$ref: "./paths/stores/deleteStore.yml" $ref: "./paths/stores/deleteStore.yml"
+36
View File
@@ -0,0 +1,36 @@
post:
operationId: updateStore
summary: Upraví existující obchod — název a/nebo seznam URL na nabídku (vyžaduje admin heslo).
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- name
- heslo
properties:
name:
description: Aktuální název obchodu (identifikuje upravovaný obchod)
type: string
newName:
description: Nový název obchodu. Pokud není předán, název zůstane nezměněn.
type: string
urls:
description: Nový seznam URL na nabídku podniku — nahradí stávající. Prázdné pole URL odstraní. Pokud není předán, URL zůstanou nezměněné.
type: array
items:
type: string
heslo:
description: Admin heslo (ADMIN_PASSWORD)
type: string
responses:
"200":
description: Obchod byl upraven
content:
application/json:
schema:
type: array
items:
$ref: "../../schemas/_index.yml#/Store"