feat: možnost editace existujících podniků u objednávání
This commit is contained in:
@@ -2,8 +2,8 @@ import { useState } from "react";
|
||||
import { Modal, Button, Form, ListGroup, Alert } from "react-bootstrap";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faTrashCan } from "@fortawesome/free-regular-svg-icons";
|
||||
import { faPlus, faUpRightFromSquare, faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
import { addStore, deleteStore, Store } from "../../../../types";
|
||||
import { faPen, faPlus, faUpRightFromSquare, faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
import { addStore, deleteStore, updateStore, Store } from "../../../../types";
|
||||
import { getStoreUrls, storeUrlLabel } from "../../utils/storeUrls";
|
||||
|
||||
type Props = {
|
||||
@@ -13,21 +13,47 @@ type Props = {
|
||||
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>) {
|
||||
const [newName, setNewName] = useState('');
|
||||
// Jeden podnik může být dostupný přes více dovozových služeb — proto seznam URL
|
||||
const [newUrls, setNewUrls] = useState<string[]>(['']);
|
||||
const [edit, setEdit] = useState<EditState | null>(null);
|
||||
const [heslo, setHeslo] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const setUrlAt = (index: number, value: string) =>
|
||||
setNewUrls(prev => prev.map((u, i) => (i === index ? value : u)));
|
||||
const addUrlRow = (urls: string[]) => [...urls, ''];
|
||||
|
||||
const addUrlRow = () => setNewUrls(prev => [...prev, '']);
|
||||
const removeUrlRow = (urls: string[], index: number) =>
|
||||
urls.length === 1 ? [''] : urls.filter((_, i) => i !== index);
|
||||
|
||||
const removeUrlRow = (index: number) =>
|
||||
setNewUrls(prev => (prev.length === 1 ? [''] : prev.filter((_, i) => i !== index)));
|
||||
const setUrlAt = (urls: string[], index: number, value: string) =>
|
||||
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 () => {
|
||||
if (!newName.trim()) return;
|
||||
@@ -36,10 +62,7 @@ export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChang
|
||||
try {
|
||||
const urls = newUrls.map(u => u.trim()).filter(Boolean);
|
||||
const res = await addStore({ body: { name: newName.trim(), urls: urls.length > 0 ? urls : undefined, heslo } });
|
||||
if (res.error) {
|
||||
setError((res.error as any).error || 'Nastala chyba');
|
||||
} else if (res.data) {
|
||||
onStoresChanged(res.data as Store[]);
|
||||
if (applyResult(res)) {
|
||||
setNewName('');
|
||||
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) => {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await deleteStore({ body: { name, heslo } });
|
||||
if (res.error) {
|
||||
setError((res.error as any).error || 'Nastala chyba');
|
||||
} else if (res.data) {
|
||||
onStoresChanged(res.data as Store[]);
|
||||
if (applyResult(res) && edit?.originalName === name) {
|
||||
setEdit(null);
|
||||
}
|
||||
} catch (e: any) {
|
||||
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 (
|
||||
<Modal show={isOpen} onHide={onClose}>
|
||||
<Modal.Header closeButton>
|
||||
@@ -100,32 +180,8 @@ export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChang
|
||||
onChange={e => setNewName(e.target.value)}
|
||||
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleAdd(); }}
|
||||
/>
|
||||
{newUrls.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 => 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>
|
||||
{renderUrlInputs(newUrls, setNewUrls, handleAdd)}
|
||||
<div className="d-flex justify-content-end mb-3">
|
||||
<Button variant="primary" onClick={handleAdd} disabled={loading || !newName.trim() || !heslo}>
|
||||
Přidat
|
||||
</Button>
|
||||
@@ -138,6 +194,37 @@ export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChang
|
||||
<ListGroup>
|
||||
{stores.map(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 (
|
||||
<ListGroup.Item key={s.name} className="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
@@ -159,6 +246,14 @@ export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChang
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
<div className="d-flex gap-3">
|
||||
<FontAwesomeIcon
|
||||
icon={faPen}
|
||||
className="action-icon"
|
||||
title="Upravit"
|
||||
onClick={() => startEdit(s)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
<FontAwesomeIcon
|
||||
icon={faTrashCan}
|
||||
className="action-icon"
|
||||
@@ -166,6 +261,7 @@ export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChang
|
||||
onClick={() => handleRemove(s.name)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
</div>
|
||||
</ListGroup.Item>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
[
|
||||
"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)",
|
||||
"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"
|
||||
]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import express from "express";
|
||||
import { getStores, addStore, removeStore } from "../stores";
|
||||
import { getStores, addStore, updateStore, removeStore } from "../stores";
|
||||
|
||||
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) => {
|
||||
const { name, heslo } = req.body ?? {};
|
||||
if (!name || typeof name !== 'string') {
|
||||
|
||||
@@ -95,6 +95,46 @@ export async function addStore(name: string, heslo: string, urls?: string[]): Pr
|
||||
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).
|
||||
*
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { resetMemoryStorage } from '../storage/memory';
|
||||
import getStorage from '../storage';
|
||||
import { getStores, addStore, removeStore } from '../stores';
|
||||
import { getStores, addStore, updateStore, removeStore } from '../stores';
|
||||
|
||||
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', () => {
|
||||
beforeEach(async () => {
|
||||
await addStore('McDonald\'s', ADMIN_PW);
|
||||
|
||||
@@ -152,6 +152,8 @@ paths:
|
||||
$ref: "./paths/stores/listStores.yml"
|
||||
/stores/add:
|
||||
$ref: "./paths/stores/addStore.yml"
|
||||
/stores/update:
|
||||
$ref: "./paths/stores/updateStore.yml"
|
||||
/stores/delete:
|
||||
$ref: "./paths/stores/deleteStore.yml"
|
||||
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user