This commit is contained in:
2025-10-02 00:54:34 +02:00
commit 84b34c9615
200 changed files with 42048 additions and 0 deletions

3
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,3 @@
node_modules
dist
.git

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

16
frontend/Dockerfile.prod Normal file
View File

@@ -0,0 +1,16 @@
# Step 1: Build React (Vite) app
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
# If package-lock.json exists, npm ci is faster and reproducible
RUN npm ci || npm install
COPY . .
ENV NODE_ENV=production
RUN npm run build
# Step 2: Nginx runtime
FROM nginx:1.27-alpine
COPY ./nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

0
frontend/README.md Normal file
View File

29
frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,29 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
},
},
])

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React</title>
</head>
<body>
<div id="app" class="d-flex flex-column min-vh-100"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

4298
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

51
frontend/package.json Normal file
View File

@@ -0,0 +1,51 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@apidevtools/swagger-parser": "^10.0.2",
"@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/react-fontawesome": "^0.2.2",
"@mantine/core": "^8.2.3",
"@mantine/dates": "^8.2.3",
"@mantine/hooks": "^8.2.3",
"@tabler/icons-react": "^3.34.1",
"@tanstack/react-table": "^8.21.3",
"axios": "^1.10.0",
"bootstrap": "^5.3.7",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"dayjs": "^1.11.13",
"lodash": "^4.17.21",
"qrcode.react": "^4.2.0",
"react": "^19.1.0",
"react-bootstrap": "^2.10.10",
"react-dom": "^19.1.0",
"react-grid-layout": "^1.5.2",
"react-qr-code": "^2.0.18",
"react-router-dom": "^7.7.1",
"use-debounce": "^10.0.5"
},
"devDependencies": {
"@eslint/js": "^9.29.0",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.5.2",
"eslint": "^9.29.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.2.0",
"postcss": "^8.5.6",
"postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1",
"vite": "^7.0.5"
}
}

View File

@@ -0,0 +1,14 @@
module.exports = {
plugins: {
'postcss-preset-mantine': {},
'postcss-simple-vars': {
variables: {
'mantine-breakpoint-xs': '36em',
'mantine-breakpoint-sm': '48em',
'mantine-breakpoint-md': '62em',
'mantine-breakpoint-lg': '75em',
'mantine-breakpoint-xl': '88em',
},
},
},
};

BIN
frontend/public/img/bg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 380 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

1
frontend/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

19
frontend/src/App.css Normal file
View File

@@ -0,0 +1,19 @@
.tab-content{
margin: auto;
width: -webkit-fill-available;
}
.nav-tabs{
margin: 0 !important;
}
.app-container {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.app-content {
flex: 1 0 auto;
/* Zabrání překrytí footerem */
overflow-y: scroll;
}

155
frontend/src/App.jsx Normal file
View File

@@ -0,0 +1,155 @@
import { useState } from "react";
import "./App.css";
import "/node_modules/react-grid-layout/css/styles.css";
import "/node_modules/react-resizable/css/styles.css";
import { Routes, Route } from "react-router-dom";
import NavBar from "./components/NavBar";
import Login from "./pages/Login";
import Register from "./pages/register/Register";
import Test from "./pages/Test";
import EmailVerificationPage from "./pages/register/EmailVerification";
import Home from "./pages/Home";
import PaymentPage from "./pages/PaymentPage";
import ResetPasswordPage from "./pages/PasswordReset";
import UserSettings from "./pages/manager/UserSettings";
{/* Security routes */}
import RequireRole from "./components/security/RequireRole";
import RequireAuthLayout from "./components/security/RequireAuthLayout";
{/* manager */}
import Events from "./pages/manager/Events";
import MapEditor from "./pages/manager/edit/MapEditor"; {/* Map editor for events */ }
import CreateEvent from "./pages/manager/create/create-event";
import Squares from "./pages/manager/Squares";
import SquareDesigner from "./pages/manager/create/SquareDesigner"; {/* Square designer for creating squares */ }
import Reservations from "./pages/manager/Reservations";
import Orders from "./pages/manager/Orders";
import Ticket from "./pages/Ticket";
import Users from "./pages/manager/Users";
import CreateUser from "./pages/manager/create/create-user";
{/* Cart for reservations (multipurpouse)*/}
import ReservationCart from "./pages/Reservation-cart"
import { UserProvider } from './context/UserContext';
// Add products pages
import Products from "./pages/manager/Products";
import CreateProduct from "./pages/manager/create/create-product";
import SettingsPage from "./pages/Settings";
function App() {
return (
<div className="app-container">
<UserProvider>
<header>
<NavBar />
</header>
<main className="app-content">
<Routes>
<Route path="/" element={<Login />} />
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
// after user registers, they will be redirected from email, to the
email verification page
<Route path="/email-verification" element={<EmailVerificationPage />} />
<Route path="/email-verification/:uidb64/:token" element={<EmailVerificationPage />}/>
<Route path="/reset-password" element={<ResetPasswordPage />} />
<Route path="/reset-password/:uidb64/:token" element={<ResetPasswordPage />}/>
{/*test*/}
{/*<Route path="/seller/reservation" element={<SelectReservation />} />*/}
{/* AUTHENTICATED */}
<Route element={<RequireAuthLayout />}>
<Route path="/tickets" element={<Ticket />} />
<Route path="/home" element={<Home />} />
<Route path="/settings" element={<UserSettings />} />
<Route path="/payment/:orderId" element={<PaymentPageWrapper />} />
{/* ADMIN */}
<Route element={<RequireRole roles={["admin"]} />}>
<Route path="/test" element={<Test />} />
</Route>
{/* SELLER && ADMIN */}
<Route element={<RequireRole roles={["seller", "admin"]} />}>
<Route path="/create-reservation" element={<ReservationCart />} />
</Route>
{/* CLERK & ADMIN */}
<Route element={<RequireRole roles={[ "admin"]} />} >
<Route path="/manage/users" element={<Users />} />
<Route path="/manage/users/create" element={<CreateUser />} />
<Route path="/manage/squares" element={<Squares />} />
<Route path="/manage/squares/designer" element={<SquareDesigner />} /> {/* Designer for squares (creation) */}
<Route path="/manage/reservations" element={<Reservations />} />
<Route path="/manage/reservations/create" element={<Reservations />} />
<Route path="/manage/events" element={<Events />} />
<Route path="/manage/events/:id" element={<Events />} />
<Route path="/manage/events/map/:eventId" element={<MapEditor />} />
<Route path="/manage/events/create" element={<CreateEvent />} />
<Route path="/manage/orders" element={<Orders />} />
{/* Products */}
<Route path="/manage/products" element={<Products />} />
<Route path="/manage/products/create" element={<CreateProduct />} />
<Route path="/manage/bin" element={<CreateProduct />} />
{/* Settings */}
<Route path="/manage/settings" element={<SettingsPage />} />
</Route>
</Route>
</Routes>
</main>
</UserProvider>
<footer className="mt-5 ">
<p>
eTržnice ©
<a href="mailto:helpdesk@vitkovice.com">
{" "}
VÍTKOVICE IT SOLUTIONS a.s.{" "}
</a>
|<a href="/test"> Nápověda</a>
</p>
</footer>
</div>
);
}
import { useParams } from "react-router-dom";
function PaymentPageWrapper() {
const { orderId } = useParams();
return <PaymentPage orderId={orderId} />;
}
export default App;

254
frontend/src/api/auth.js Normal file
View File

@@ -0,0 +1,254 @@
import axios from "axios";
const API_URL = `${import.meta.env.VITE_BACKEND_URL}/api`;
// Axios instance for authenticated requests
const axios_instance = axios.create({
baseURL: API_URL,
withCredentials: true,
});
axios_instance.defaults.xsrfCookieName = "csrftoken";
axios_instance.defaults.xsrfHeaderName = "X-CSRFToken";
// Axios instance without Authorization for auth endpoints (refresh/login/logout)
const axios_no_auth = axios.create({
baseURL: API_URL,
withCredentials: true,
});
axios_no_auth.defaults.xsrfCookieName = "csrftoken";
axios_no_auth.defaults.xsrfHeaderName = "X-CSRFToken";
// CSRF helper for authless client
const addCsrfHeader = (config) => {
const getCookie = (name) => {
let cookieValue = null;
if (document.cookie && document.cookie !== "") {
const cookies = document.cookie.split(";");
for (let cookie of cookies) {
cookie = cookie.trim();
if (cookie.startsWith(name + "=")) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
};
const token = getCookie("csrftoken");
const method = (config.method || "").toLowerCase();
if (token && ["post", "put", "patch", "delete"].includes(method)) {
config.headers["X-CSRFToken"] = token;
}
// Ensure no Authorization on authless client
if (config.headers && "Authorization" in config.headers) {
delete config.headers.Authorization;
}
return config;
};
// Attach CSRF only to authless client
axios_no_auth.interceptors.request.use(addCsrfHeader);
// Flag to prevent multiple simultaneous refresh attempts
let isRefreshing = false;
let failedQueue = [];
const processQueue = (error, token = null) => {
failedQueue.forEach((prom) => {
if (error) {
prom.reject(error);
} else {
prom.resolve(token);
}
});
failedQueue = [];
};
// Response interceptor for token refresh
axios_instance.interceptors.response.use(
(response) => response,
async (error) => {
const { response, config } = error;
// Only handle 401 errors
if (!response || response.status !== 401) {
return Promise.reject(error);
}
const originalRequest = config || {};
const url = (originalRequest?.url || "").toString();
// Skip auth endpoints, redirect directly
if (url.includes("/account/token/") || url.includes("/account/logout/")) {
window.location.href = "/login";
return Promise.reject(error);
}
// If already tried to refresh, redirect to login
if (originalRequest._retry) {
window.location.href = "/login";
return Promise.reject(error);
}
// If currently refreshing, queue the request
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
})
.then((token) => {
originalRequest.headers["Authorization"] = `Bearer ${token}`;
return axios_instance(originalRequest);
})
.catch((err) => {
return Promise.reject(err);
});
}
originalRequest._retry = true;
isRefreshing = true;
try {
const refreshResponse = await refreshAccessToken();
if (refreshResponse && refreshResponse.access) {
const newToken = refreshResponse.access;
axios_instance.defaults.headers.common["Authorization"] = `Bearer ${newToken}`;
originalRequest.headers["Authorization"] = `Bearer ${newToken}`;
processQueue(null, newToken);
return axios_instance(originalRequest);
} else {
processQueue(error, null);
window.location.href = "/login";
return Promise.reject(error);
}
} catch (refreshError) {
processQueue(refreshError, null);
window.location.href = "/login";
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
);
// Token refresh function - uses axios_no_auth to avoid interceptor loops
export const refreshAccessToken = async () => {
const refreshToken = localStorage.getItem("refresh_token");
if (!refreshToken) return null;
try {
const res = await axios_no_auth.post(`/account/token/refresh/`, {
refresh: refreshToken,
});
if (res?.data?.access) {
// Don't set the Authorization header here, let the interceptor handle it
return res.data;
}
return null;
} catch (err) {
console.error("Token refresh failed", err);
return null;
}
};
// Login function
export const login = async (username, password) => {
clearTokens();
try {
const response = await axios_no_auth.post(`/account/token/`, { username, password });
if (response?.data?.access) {
localStorage.setItem("access_token", response.data.access);
axios_instance.defaults.headers.common.Authorization = `Bearer ${response.data.access}`;
}
if (response?.data?.refresh) {
localStorage.setItem("refresh_token", response.data.refresh);
}
return response.data;
} catch (err) {
if (err.response) {
console.log("Login error status:", err.response.status);
} else if (err.request) {
console.log("Login network error:", err.request);
} else {
console.log("Login setup error:", err.message);
}
throw err;
}
};
// Logout function
export const logout = async () => {
try {
const response = await axios_no_auth.post("/account/logout/", {});
// Clear the Authorization header
delete axios_instance.defaults.headers.common.Authorization;
console.log("Logout successful:", response.data);
return response.data;
} catch (err) {
console.error("Logout failed", err);
// Still clear the header even if logout fails
delete axios_instance.defaults.headers.common.Authorization;
throw err;
}
};
// API request function
export const apiRequest = async (method, endpoint, data = {}, config = {}) => {
const url = endpoint.startsWith("/") ? endpoint : `/${endpoint}`;
try {
const response = await axios_instance({
method,
url,
data: ["post", "put", "patch"].includes(method.toLowerCase()) ? data : undefined,
params: ["get", "delete"].includes(method.toLowerCase()) ? data : undefined,
...config,
});
return response.data;
} catch (err) {
if (err.response) {
console.error("API Error:", {
status: err.response.status,
data: err.response.data,
headers: err.response.headers,
});
} else if (err.request) {
console.error("No response received:", err.request);
} else {
console.error("Request setup error:", err.message);
}
throw err;
}
};
// Get current user
export async function getCurrentUser() {
const response = await axios_instance.get(`/account/user/me/`);
return response.data;
}
// Check if authenticated
export async function isAuthenticated() {
try {
const user = await getCurrentUser();
return user != null;
} catch (err) {
return false; // pokud padne 401, není přihlášen
}
}
// Clear tokens function
function clearTokens() {
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
delete axios_instance.defaults.headers.common.Authorization;
}
export default axios_instance;
export { axios_instance, API_URL };

View File

@@ -0,0 +1,41 @@
import { apiRequest } from "./auth";
/**
* Načte enum hodnoty z OpenAPI schématu pro zadanou cestu, metodu a pole (např. category).
*
* @param {string} path - API cesta, např. "/api/service-tickets/"
* @param {"get"|"post"|"patch"|"put"} method - HTTP metoda
* @param {string} field - název pole v parametrech nebo requestu
* @param {string} schemaUrl - URL JSON schématu, výchozí "/api/schema/?format=json"
* @returns {Promise<Array<{ value: string, label: string }>>}
*/
export async function fetchEnumFromSchemaJson(
path,
method,
field,
schemaUrl = "/schema/?format=json"
) {
try {
const schema = await apiRequest("get", schemaUrl);
const methodDef = schema.paths?.[path]?.[method];
if (!methodDef) {
throw new Error(`Metoda ${method.toUpperCase()} pro ${path} nebyla nalezena ve schématu.`);
}
// Hledáme ve "parameters" (např. GET query parametry)
const param = methodDef.parameters?.find((p) => p.name === field);
if (param?.schema?.enum) {
return param.schema.enum.map((val) => ({
value: val,
label: val,
}));
}
throw new Error(`Pole '${field}' neobsahuje enum`);
} catch (error) {
console.error("Chyba při načítání enum hodnot:", error);
throw error;
}
}

View File

@@ -0,0 +1,89 @@
import axios_instance from '../auth';
/**
* Django REST endpoints (configuration app - AppConfig singleton):
* Base router mounted under /config/ => list/create/retrieve/update.
* Standard DRF router patterns (basename='app_config'):
* - GET /config/ -> list (will contain max 1 item)
* - POST /config/ -> create (only when no instance exists)
* - GET /config/{id}/ -> retrieve singleton (id ignored internally)
* - PATCH /config/{id}/ -> partial update
* - PUT /config/{id}/ -> full update
*/
const API_CONFIG_BASE = '/config';
const API_CONFIG_PUBLIC = '/config/public';
/**
* @typedef {Object} AppConfig
* @property {number} id
* @property {string|null} bank_account
* @property {string} sender_email
* @property {string|null} background_image URL
* @property {string|null} logo URL
* @property {number|null} variable_symbol
* @property {number} max_reservations_per_event
* @property {string|null} contact_phone
* @property {string|null} contact_email
* @property {string} last_changed_at ISO timestamp
* @property {number|null} last_changed_by user id
*/
/**
* Fetch current AppConfig singleton (convenience helper).
* @returns {Promise<AppConfig|null>}
*/
export const getAppConfig = async () => {
const response = await axios_instance.get(`${API_CONFIG_BASE}/`);
const data = response.data;
// If ViewSet returns list
if (Array.isArray(data)) {
return data[0] ?? null;
}
// In some customizations it might return object directly
return data;
};
/**
* Fetch public (read-only) subset for anonymous / navbar usage.
* @returns {Promise<{logo:string|null, background_image:string|null, contact_email:string|null, contact_phone:string|null}|null>}
*/
export const getPublicAppConfig = async (fields) => {
try {
const params = fields ? { fields: Array.isArray(fields) ? fields.join(',') : fields } : undefined;
const response = await axios_instance.get(`${API_CONFIG_PUBLIC}/`, { params });
return response.data;
} catch (e) {
return null; // not configured yet or invalid fields
}
};
/**
* Create AppConfig (only when none exists).
* @param {Partial<AppConfig>} payload
* @returns {Promise<AppConfig>}
*/
export const createAppConfig = async (payload) => {
const response = await axios_instance.post(`${API_CONFIG_BASE}/`, payload);
return response.data;
};
/**
* Update (PATCH) existing AppConfig.
* Supports JSON or multipart form (for image uploads). Pass FormData for files.
* @param {number} id
* @param {Object|FormData} payload
* @returns {Promise<AppConfig>}
*/
export const updateAppConfig = async (id, payload) => {
const isFormData = (typeof FormData !== 'undefined') && payload instanceof FormData;
// Don't set Content-Type manually for FormData (axios will add boundary automatically)
const response = await axios_instance.patch(`${API_CONFIG_BASE}/${id}/`, payload, isFormData ? {} : undefined);
return response.data;
};
export default {
getAppConfig,
createAppConfig,
updateAppConfig,
getPublicAppConfig,
};

View File

@@ -0,0 +1,78 @@
import axios_instance from '../auth';
const API_BASE_URL = '/booking/bins';
/**
* GET seznam košů (Bin).
*
* Query parametry:
* @param {Object} params
* - search: {string} - fulltextové hledání v poli name, description, location, atd.
* - status: {string} - stav koše (např. "active")
* - location: {string} - umístění
* - capacity: {string|number} - kapacita
* - ordering: {string} - např. "name" nebo "-capacity"
*
* @returns {Promise<Array<Bin>>}
*/
export const getBins = async (params = {}) => {
const response = await axios_instance.get(API_BASE_URL + '/', { params });
return response.data;
};
/**
* GET detail konkrétního koše.
*
* @param {number} id - ID koše
* @returns {Promise<Bin>}
*/
export const getBinById = async (id) => {
const response = await axios_instance.get(`${API_BASE_URL}/${id}/`);
return response.data;
};
/**
* PATCH - částečná aktualizace koše.
*
* @param {number} id - ID koše
* @param {Object} data - Libovolná pole z modelu Bin, která se mají změnit:
* - name?: {string}
* - description?: {string}
* - status?: {string}
* - location?: {string}
* - capacity?: {number}
* @returns {Promise<Bin>}
*/
export const updateBin = async (id, data) => {
const response = await axios_instance.patch(`${API_BASE_URL}/${id}/`, data);
return response.data;
};
/**
* POST - vytvoření nového koše.
*
* @param {Object} data - Data pro nový koš
* @returns {Promise<Bin>}
*/
export const createBin = async (data) => {
const response = await axios_instance.post(`${API_BASE_URL}/`, data);
return response.data;
};
/**
* DELETE - odstranění koše podle ID.
*
* @param {number} id - ID koše
* @returns {Promise<void>} - HTTP 204 No Content při úspěchu
*/
export const deleteBin = async (id) => {
await axios_instance.delete(`${API_BASE_URL}/${id}/`);
};
export default {
getBins,
getBinById,
updateBin,
deleteBin,
createBin,
};

View File

@@ -0,0 +1,79 @@
import axios_instance from '../auth';
const API_BASE_URL = '/products/event-products';
/**
* GET seznam produktů.
*
* @param {Object} params - Možné query parametry (dle backendu), např.:
* - search: {string} hledání v názvu/popisu
* - ordering: {string} např. "-created_at"
* - is_active: {boolean} filtr aktivních
*
* @returns {Promise<Array<Product>>}
*/
export const getEventProducts = async (params = {}) => {
const response = await axios_instance.get(`${API_BASE_URL}/`, { params });
return response.data;
};
/**
* GET detail konkrétního produktu.
*
* @param {number} id - ID produktu
* @returns {Promise<Product>}
*/
export const getEventProductById = async (id) => {
const response = await axios_instance.get(`${API_BASE_URL}/${id}/`);
return response.data;
};
/**
* POST - vytvoření nového produktu.
*
* @param {Object} data - Data nového produktu:
* - name: {string} název produktu
* - description?: {string} popis
* - price?: {number} cena v Kč
* - is_active?: {boolean} zda je aktivní
*
* @returns {Promise<Product>}
*/
export const createEventProduct = async (data) => {
const response = await axios_instance.post(`${API_BASE_URL}/`, data);
return response.data;
};
/**
* PATCH - částečná aktualizace produktu.
*
* @param {number} id - ID produktu
* @param {Object} data - Libovolné pole z:
* - name?: {string}
* - description?: {string}
* - price?: {number}
* - is_active?: {boolean}
* @returns {Promise<Product>}
*/
export const updateEventProduct = async (id, data) => {
const response = await axios_instance.patch(`${API_BASE_URL}/${id}/`, data);
return response.data;
};
/**
* DELETE - smazání produktu.
*
* @param {number} id - ID produktu
* @returns {Promise<void>} HTTP 204 při úspěchu
*/
export const deleteEventProduct = async (id) => {
await axios_instance.delete(`${API_BASE_URL}/${id}/`);
};
export default {
getEventProducts,
getEventProductById,
createEventProduct,
updateEventProduct,
deleteEventProduct,
};

View File

@@ -0,0 +1,76 @@
import axios_instance from '../auth';
const API_BASE_URL = '/booking/events';
/**
* GET seznam událostí (Event).
*
* Query parametry:
* @param {Object} params
* - search: {string} - fulltextové hledání v poli name, description, square.name, atd.
* - city: {string} - název města (např. "Ostrava")
* - start_after: {string} - od data (ISO datetime)
* - end_before: {string} - do data (ISO datetime)
* - square_size: {string} - velikost náměstí (např. "100" pro 100 m²)
* - ordering: {string} - např. "name" nebo "-start"
*
* @returns {Promise<Array<Event>>}
*/
export const getEvents = async (params = {}) => {
const response = await axios_instance.get(API_BASE_URL + '/', { params });
return response.data;
};
/**
* GET detail konkrétní události.
*
* @param {number} id - ID události
* @returns {Promise<Event>}
*/
export const getEventById = async (id) => {
const response = await axios_instance.get(`${API_BASE_URL}/${id}/`);
return response.data;
};
/**
* PATCH - částečná aktualizace události.
*
* @param {number} id - ID události
* @param {Object} data - Libovolná pole z modelu Event, která se mají změnit:
* - name?: {string}
* - description?: {string}
* - start?: {string} ISO datetime
* - end?: {string} ISO datetime
* - square?: {number}
* @returns {Promise<Event>}
*/
export const updateEvent = async (id, data) => {
const response = await axios_instance.patch(`${API_BASE_URL}/${id}/`, data);
return response.data;
};
export const createEvent = async (formData) => {
const response = await axios_instance.post(`${API_BASE_URL}/`, formData, {
headers: { "Content-Type": "multipart/form-data" },
});
return response.data;
};
/**
* DELETE - odstranění události podle ID.
*
* @param {number} id - ID události
* @returns {Promise<void>} - HTTP 204 No Content při úspěchu
*/
export const deleteEvent = async (id) => {
await axios_instance.delete(`${API_BASE_URL}/${id}/`);
};
export default {
getEvents,
getEventById,
updateEvent,
deleteEvent,
createEvent,
};

View File

@@ -0,0 +1,74 @@
import axios_instance from '../auth';
const MARKET_SLOTS_API_URL = '/booking/market-slots/';
/**
* Získá seznam všech prodejních míst s možností filtrování.
* @param {Object} params - Volitelné parametry:
* - event: ID události (integer)
* - status: stav slotu (empty/blocked/taken)
* - ordering: řazení podle pole (např. `x`, `-y`, ...)
* @returns {Promise<Array>} - Pole objektů `MarketSlot`
*/
export const getMarketSlots = async (params = {}) => {
const response = await axios_instance.get(MARKET_SLOTS_API_URL, { params });
return response.data;
};
/**
* Vytvoří nové prodejní místo.
* @param {Object} data - Objekt s daty pro nové prodejní místo ve formátu dle API:
* - event: ID události (povinné)
* - status: stav (empty/blocked/taken)
* - base_size: základní velikost v m²
* - available_extension: možnost rozšíření v m²
* - x: X souřadnice
* - y: Y souřadnice
* - width: šířka slotu
* - height: výška slotu
* - price_per_m2: cena za m²
* @returns {Promise<Object>} - Vytvořený objekt `MarketSlot`
*/
export const createMarketSlot = async (data) => {
const response = await axios_instance.post(MARKET_SLOTS_API_URL, data);
return response.data;
};
/**
* Získá detail konkrétního prodejního místa podle ID.
* @param {number} id - ID prodejního místa
* @returns {Promise<Object>} - Objekt `MarketSlot`
*/
export const getMarketSlotById = async (id) => {
const response = await axios_instance.get(`${MARKET_SLOTS_API_URL}${id}/`);
return response.data;
};
/**
* Částečně aktualizuje prodejní místo (PATCH).
* @param {number} id - ID prodejního místa k úpravě
* @param {Object} data - Částečný objekt s vlastnostmi k aktualizaci
* @returns {Promise<Object>} - Aktualizovaný objekt `MarketSlot`
*/
export const updateMarketSlot = async (id, data) => {
const response = await axios_instance.patch(`${MARKET_SLOTS_API_URL}${id}/`, data);
return response.data;
};
/**
* Smaže konkrétní prodejní místo podle ID.
* @param {number} id - ID prodejního místa
* @returns {Promise<void>} - Úspěšný DELETE vrací 204 bez obsahu
*/
export const deleteMarketSlot = async (id) => {
const response = await axios_instance.delete(`${MARKET_SLOTS_API_URL}${id}/`);
return response.data;
};
export default {
getMarketSlots,
getMarketSlotById,
createMarketSlot,
updateMarketSlot,
deleteMarketSlot,
};

View File

@@ -0,0 +1,82 @@
import axios_instance from '../auth';
const API_BASE_URL = '/commerce/orders';
/**
* GET seznam objednávek.
*
* @param {Object} params - Možné query parametry:
* - reservation: {number} ID rezervace
* - user: {number} ID uživatele
* - ordering: {string} např. "-created_at"
* - search: {string} hledání napříč uživatelem, poznámkou, názvem události atd.
*
* @returns {Promise<Array<Order>>}
*/
export const getOrders = async (params = {}) => {
const response = await axios_instance.get(`${API_BASE_URL}/`, { params });
return response.data;
};
/**
* GET detail konkrétní objednávky.
*
* @param {number} id - ID objednávky
* @returns {Promise<Order>}
*/
export const getOrderById = async (id) => {
const response = await axios_instance.get(`${API_BASE_URL}/${id}/`);
return response.data;
};
/**
* POST - vytvoření nové objednávky.
*
* @param {Object} data - Data nové objednávky:
* - reservation: {number} ID rezervace
* - price?: {string} vlastní cena (volitelné, pokud se liší od ceny rezervace)
*
* @returns {Promise<Order>}
*/
export const createOrder = async (data) => {
const response = await axios_instance.post(`${API_BASE_URL}/`, data);
return response.data;
};
/**
* PATCH - částečná aktualizace objednávky.
*
* @param {number} id - ID objednávky
* @param {Object} data - Libovolné pole z:
* - price?: {string}
*
* @returns {Promise<Order>}
*/
export const updateOrder = async (id, data) => {
const response = await axios_instance.patch(`${API_BASE_URL}/${id}/`, data);
return response.data;
};
/**
* DELETE - smazání objednávky.
*
* @param {number} id - ID objednávky
* @returns {Promise<void>} HTTP 204 při úspěchu
*/
export const deleteOrder = async (id) => {
await axios_instance.delete(`${API_BASE_URL}/${id}/`);
};
export const calculatePrice = async (data) => {
const res = await axios_instance.post("/commerce/calculate_price/", data);
return res.data;
};
export default {
calculatePrice,
getOrders,
getOrderById,
createOrder,
updateOrder,
deleteOrder,
};

View File

@@ -0,0 +1,98 @@
import axios_instance from '../auth';
/**
* Django REST endpoints (product app):
* - Products: /products/products/
*
* NOTE: These paths assume `backend/product/urls.py` is included under `/products/` in the root urls.
*/
const API_PRODUCTS_BASE = '/products';
/**
* @typedef {Object} Product
* @property {number} id
* @property {string} name Název zboží (max 255 znaků)
* @property {string} code Unikátní kód (např. "FOOD-001")
*/
/**
* GET seznam produktů.
*
* Podporované query parametry (pokud povolí viewset/filter backend):
* - search?: string full-text dle backendu
* - ordering?: string např. "name" nebo "-name"
*
* @param {Object} params
* @returns {Promise<Array<Product>>}
*
* Příklad:
* getProducts({ search: 'med', ordering: 'name' })
*/
export const getProducts = async (params = {}) => {
const response = await axios_instance.get(`${API_PRODUCTS_BASE}/`, { params });
return response.data;
};
/**
* GET detail produktu.
* @param {number} id
* @returns {Promise<Product>}
*/
export const getProductById = async (id) => {
const response = await axios_instance.get(`${API_PRODUCTS_BASE}/${id}/`);
return response.data;
};
/**
* POST vytvoření produktu.
*
* Body:
* - name: string (required)
* - code: string (required, unikátní)
*
* @param {{name:string, code:string}} data
* @returns {Promise<Product>}
*
* Příklad:
* createProduct({ name: 'Med květový', code: 'FOOD-001' })
*/
export const createProduct = async (data) => {
const response = await axios_instance.post(`${API_PRODUCTS_BASE}/`, data);
return response.data;
};
/**
* PATCH částečná aktualizace produktu.
*
* Body (libovolná kombinace):
* - name?: string
* - code?: string
*
* @param {number} id
* @param {{name?:string, code?:string}} data
* @returns {Promise<Product>}
*
* Příklad:
* updateProduct(12, { name: 'Med lesní' })
*/
export const updateProduct = async (id, data) => {
const response = await axios_instance.patch(`${API_PRODUCTS_BASE}/${id}/`, data);
return response.data;
};
/**
* DELETE produkt.
* @param {number} id
* @returns {Promise<void>} HTTP 204 on success
*/
export const deleteProduct = async (id) => {
await axios_instance.delete(`${API_PRODUCTS_BASE}/${id}/`);
};
export default {
getProducts,
getProductById,
createProduct,
updateProduct,
deleteProduct,
};

View File

@@ -0,0 +1,96 @@
import axios_instance from '../auth';
const API_BASE_URL = '/booking/reservations';
/**
* GET seznam rezervací.
*
* @param {Object} params - Možné query parametry:
* - event: {number} ID události
* - user: {number} ID uživatele
* - status: {'reserved'|'cancelled'} Filtr na stav rezervace
* - Reservationing: {string} např. "-created_at"
* - search: {string} hledání v poli poznámka, uživatel, název události atd.
*
* @returns {Promise<Array<Reservation>>}
*/
export const getReservations = async (params = {}) => {
const response = await axios_instance.get(`${API_BASE_URL}/`, { params });
return response.data;
};
/**
* GET detail konkrétní rezervace.
*
* @param {number} id - ID rezervace
* @returns {Promise<Reservation>}
*/
export const getReservationById = async (id) => {
const response = await axios_instance.get(`${API_BASE_URL}/${id}/`);
return response.data;
};
/**
* POST - vytvoření nové rezervace.
*
* @param {Object} data - Data nové rezervace:
* - event: {number} ID události
* - user: {number} ID uživatele (většinou backend vyplní automaticky podle tokenu)
* - note?: {string} poznámka k rezervaci
* - status?: {'reserved'|'cancelled'} (výchozí "reserved")
* - cells: {number[]} seznam ID rezervovaných buněk
*
* @returns {Promise<Reservation>}
*/
export const createReservation = async (data) => {
const response = await axios_instance.post(`${API_BASE_URL}/`, data);
return response.data;
};
/**
* PATCH - částečná aktualizace rezervace.
*
* @param {number} id - ID rezervace
* @param {Object} data - Libovolné pole z:
* - event?: {number}
* - note?: {string}
* - status?: {'reserved'|'cancelled'}
* - cells?: {number[]}
* @returns {Promise<Reservation>}
*/
export const updateReservation = async (id, data) => {
const response = await axios_instance.patch(`${API_BASE_URL}/${id}/`, data);
return response.data;
};
/**
* DELETE - smazání rezervace.
*
* @param {number} id - ID rezervace
* @returns {Promise<void>} HTTP 204 při úspěchu
*/
export const deleteReservation = async (id) => {
await axios_instance.delete(`${API_BASE_URL}/${id}/`);
};
/**
* GET rezervované rozsahy pro konkrétní slot.
*
* @param {number} slotId - ID slotu
* @returns {Promise<Array<{start: string, end: string}>>}
*/
export const getReservedRanges = async (market_slot_id) => {
const response = await axios_instance.get(`/booking/reserved-days-check/`, {
params: { market_slot_id: market_slot_id }
});
return response.data;
};
export default {
getReservations,
getReservationById,
getReservedRanges,
createReservation,
updateReservation,
deleteReservation,
};

View File

@@ -0,0 +1,75 @@
import axios_instance from '../auth';
const SQUARE_API_URL = '/booking/squares/';
/**
* Získá seznam všech náměstí s možností filtrování a fulltextového vyhledávání.
* @param {Object} params - Volitelné parametry:
* - city: podle města (string)
* - psc: podle PSČ (integer)
* - width: šířka (integer)
* - height: výška (integer)
* - search: fulltext (string)
* - ordering: řazení podle pole (např. `name`, `-city`, ...)
* @returns {Promise<Array>} - Pole objektů `Square`
*/
export const getSquares = async (params = {}) => {
const response = await axios_instance.get(SQUARE_API_URL, { params });
return response.data;
};
/**
* Získá detail konkrétního náměstí podle ID.
* @param {number} id - ID náměstí
* @returns {Promise<Object>} - Objekt `Square`
*/
export const getSquareById = async (id) => {
const response = await axios_instance.get(`${SQUARE_API_URL}${id}/`);
console.log(response.data);
return response.data;
};
/**
* Aktualizuje celé náměstí (PATCH).
* @param {number} id - ID náměstí k úpravě
* @param {Object} data - Kompletní objekt náměstí ve formátu dle API (např. `name`, `city`, `width`, `height`, `description`)
* @returns {Promise<Object>} - Aktualizovaný objekt `Square`
*/
export const updateSquare = async (id, data) => {
const response = await axios_instance.patch(`${SQUARE_API_URL}${id}/`, data);
return response.data;
};
/**
* Vytvoří nové náměstí (POST).
* @param {Object} data - Objekt náměstí
* @returns {Promise<Object>} - Vytvořené náměstí
*/
export const createSquare = async (data) => {
const response = await axios_instance.post(SQUARE_API_URL, data, {
headers: { 'Content-Type': 'multipart/form-data' }
});
return response.data;
};
/**
* Smaže konkrétní náměstí podle ID.
* @param {number} id - ID náměstí
* @returns {Promise<void>} - Úspěšný DELETE vrací 204 bez obsahu
*/
export const deleteSquare = async (id) => {
const response = await axios_instance.delete(`${SQUARE_API_URL}${id}/`);
return response.data;
};
export default {
getSquares,
getSquareById,
updateSquare,
deleteSquare,
createSquare
};

View File

@@ -0,0 +1,85 @@
import axios_instance from "../auth";
const API_BASE_URL = "/service-tickets";
/**
* GET seznam tiketů.
*
* @param {Object} params - Možné query parametry:
* - user: {number} ID uživatele
* - status: {'new'|'in_progress'|'resolved'|'closed'}
* - category: {'tech'|'ServiceTicket'|'payment'|'account'|'content'|'suggestion'|'other'}
* - ordering: {string} např. "-created_at"
* - search: {string} hledání v názvu nebo popisu
*
* @returns {Promise<Array<ServiceTicket>>}
*/
export const getServiceTickets = async (params = {}) => {
const response = await axios_instance.get(`${API_BASE_URL}/`, { params });
return response.data;
};
/**
* GET detail konkrétního tiketu.
*
* @param {number} id - ID tiketu
* @returns {Promise<ServiceTicket>}
*/
export const getServiceTicketById = async (id) => {
const response = await axios_instance.get(`${API_BASE_URL}/${id}/`);
return response.data;
};
/**
* POST - vytvoření nového tiketu.
*
* @param {Object} data - Data nového tiketu:
* - title: {string}
* - description?: {string}
* - user?: {number} (volitelné backend často určí automaticky dle tokenu)
* - category?: {'tech'|'ServiceTicket'|'payment'|'account'|'content'|'suggestion'|'other'}
* - status?: {'new'|'in_progress'|'resolved'|'closed'} (výchozí "new")
*
* @returns {Promise<ServiceTicket>}
*/
export const createServiceTicket = async (data) => {
const response = await axios_instance.post(`${API_BASE_URL}/`, data);
return response.data;
};
/**
* PATCH - částečná aktualizace tiketu.
*
* @param {number} id - ID tiketu
* @param {Object} data - Libovolná pole z:
* - title?: {string}
* - description?: {string}
* - category?: {string}
* - status?: {string}
*
* @returns {Promise<ServiceTicket>}
*/
export const updateServiceTicket = async (id, data) => {
const response = await axios_instance.patch(`${API_BASE_URL}/${id}/`, data);
return response.data;
};
/**
* DELETE - smazání tiketu.
*
* @param {number} id - ID tiketu
* @returns {Promise<void>} HTTP 204 při úspěchu
*/
export const deleteServiceTicket = async (id) => {
await axios_instance.delete(`${API_BASE_URL}/${id}/`);
};
export default {
getServiceTickets,
getServiceTicketById,
createServiceTicket,
updateServiceTicket,
deleteServiceTicket,
};

View File

@@ -0,0 +1,73 @@
// frontend/src/api/model/user.js
// User API model for searching users by username
// Structure matches other model files (see order.js for reference)
import axios_instance from '../auth';
const API_BASE_URL = "/account/users";
const userAPI = {
/**
* Get all users
* @returns {Promise<Array<User>>}
*/
async getUsers(params) {
const response = await axios_instance.get(`${API_BASE_URL}/`, { params });
return response.data;
},
/**
* Get a single user by ID
* @param {number|string} id
* @returns {Promise<User>}
*/
async getUser(id) {
const response = await axios_instance.get(`${API_BASE_URL}/${id}/`);
return response.data;
},
/**
* Update a user by ID
* @param {number|string} id
* @param {Object} data
* @returns {Promise<User>}
*/
async updateUser(id, data) {
const response = await axios_instance.patch(`${API_BASE_URL}/${id}/`, data);
return response.data;
},
/**
* Delete a user by ID
* @param {number|string} id
* @returns {Promise<void>}
*/
async deleteUser(id) {
const response = await axios_instance.delete(`${API_BASE_URL}/${id}/`);
return response.data;
},
/**
* Create a new user
* @param {Object} data
* @returns {Promise<User>}
*/
async createUser(data) {
const response = await axios_instance.post(`${API_BASE_URL}/`, data);
return response.data;
},
/**
* Search users by username (partial match)
* @param {Object} params - { username: string }
* @returns {Promise<Array<User>>}
*/
async searchUsers(params) {
// Adjust the endpoint as needed for your backend
const response = await axios_instance.get(`${API_BASE_URL}/`, { params });
console.log("User search response:", response.data);
return response.data;
},
};
export default userAPI;

View File

@@ -0,0 +1,34 @@
// ❌ Odhlášení:
import { logout } from "../api/auth";
logout();
//✅ Přihlášení uživatele:
import { login } from "../api/auth";
const success = await login("username", "password");
if (success) {
console.log("Přihlášení úspěšné");
} else {
alert("Chybné přihlášení");
}
// 👤 Získání přihlášeného uživatele:
import { getCurrentUser } from "../api/auth";
const user = await getCurrentUser();
if (user) {
console.log("Přihlášený uživatel:", user);
} else {
console.log("Nikdo není přihlášen");
}
// pokud dojde k 401, pokusí se obnovit token

View File

@@ -0,0 +1,46 @@
/* PŘÍKLAD FETCH Z VEŘEJNÉHO API (v swaggeru to poznáte podle odemknutého zámečku) */
import API_URL from "../auth"; // musíš si importovat API_URL z auth.js
const response = await fetch(`${API_URL}/account/registration/`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username: "exampleUser",
email: "example@example.com",
password: "tajneheslo123",
}),
});
const data = await response.json();
console.log(data);
/*---------------PRO CHRÁNĚNÉ ENDPOINTY----------------*/
import { apiRequest } from "../auth"; // důležitý helper pro chráněné API
// ✅ GET Načtení dat
const userData = await apiRequest("get", "/account/profile/");
// ✅ POST Např. vytvoření nové rezervace
const newItem = await apiRequest("post", "/reservation/create/", {
name: "Stánek s medem",
location: "A5",
});
// ✅ PUT Úplná aktualizace
const updatedItem = await apiRequest("put", "/reservation/42/", {
name: "Upravený stánek",
location: "B1",
});
// ✅ PATCH Částečná aktualizace
const partiallyUpdated = await apiRequest("patch", "/reservation/42/", {
location: "C3",
});
// ✅ DELETE Smazání záznamu
await apiRequest("delete", "/reservation/42/");

View File

@@ -0,0 +1,362 @@
[
{
"id": 1,
"name": "Tržiště Hlavní",
"description": "Centrální tržnice města s širokou nabídkou zboží.",
"street": "Hlavní 25",
"city": "Praha",
"psc": 11000,
"width": 6000,
"height": 4000,
"grid_rows": 60,
"grid_cols": 40,
"cellsize": 100,
"image": "/img/namest-1.png",
"events": [
{
"id": 201,
"name": "Letní jarmark",
"description": "Událost plná rukodělných výrobků a tradičních pokrmů.",
"start": "2025-07-22T09:00:00.000Z",
"end": "2025-07-22T19:00:00.000Z",
"price_per_m2": "380",
"image": "/img/namest-1.png",
"market_slots": [
{
"id": 3001,
"event": 201,
"status": "empty",
"base_size": 6,
"available_extension": 3,
"x": 200,
"y": 300,
"width": 300,
"height": 300,
"price_per_m2": "380"
}
]
}
]
},
{
"id": 2,
"name": "Farmářský dvůr",
"description": "Místní trhy s bio produkcí a sezónní zeleninou.",
"street": "Zahradní 3",
"city": "Olomouc",
"psc": 77900,
"width": 4000,
"height": 2500,
"grid_rows": 40,
"grid_cols": 25,
"cellsize": 100,
"image": "/img/namest-1.png",
"events": [
{
"id": 202,
"name": "Podzimní sklizeň",
"description": "Speciální prodejní den se zaměřením na jablka a dýně.",
"start": "2025-09-15T08:00:00.000Z",
"end": "2025-09-15T16:00:00.000Z",
"price_per_m2": "320",
"image": "/img/namest-1.png",
"market_slots": [
{
"id": 3002,
"event": 202,
"status": "occupied",
"base_size": 8,
"available_extension": 4,
"x": 150,
"y": 250,
"width": 400,
"height": 300,
"price_per_m2": "320"
},
{
"id": 3003,
"event": 202,
"status": "empty",
"base_size": 6,
"available_extension": 2,
"x": 600,
"y": 250,
"width": 300,
"height": 300,
"price_per_m2": "320"
}
]
},
{
"id": 203,
"name": "Letní jarmark",
"description": "Událost plná rukodělných výrobků a tradičních pokrmů.",
"start": "2025-07-22T09:00:00.000Z",
"end": "2025-07-22T19:00:00.000Z",
"price_per_m2": "380",
"image": "/img/namest-1.png",
"market_slots": [
{
"id": 3001,
"event": 201,
"status": "empty",
"base_size": 6,
"available_extension": 3,
"x": 200,
"y": 300,
"width": 300,
"height": 300,
"price_per_m2": "380"
}
]
}
]
},
{
"id": 3,
"name": "Jihotržnice",
"description": "Moderní tržiště na okraji města s parkováním.",
"street": "U Výstaviště 10",
"city": "Ostrava",
"psc": 70030,
"width": 5000,
"height": 3000,
"grid_rows": 50,
"grid_cols": 30,
"cellsize": 100,
"image": "/img/namest-1.png",
"events": [
{
"id": 203,
"name": "Festival chutí",
"description": "Gastronomický zážitek s mezinárodní kuchyní.",
"start": "2025-08-05T10:00:00.000Z",
"end": "2025-08-05T22:00:00.000Z",
"price_per_m2": "550",
"image": "/img/namest-1.png",
"market_slots": [
{
"id": 3004,
"event": 203,
"status": "reserved",
"base_size": 10,
"available_extension": 5,
"x": 100,
"y": 100,
"width": 500,
"height": 300,
"price_per_m2": "550"
}
]
}
]
},
{
"id": 4,
"name": "Tržiště Jih",
"description": "Trh v jižní části města zaměřený na lokální produkty.",
"street": "Jižní 5",
"city": "Brno",
"psc": 60200,
"width": 5000,
"height": 3000,
"grid_rows": 50,
"grid_cols": 30,
"cellsize": 100,
"image": "/img/namest-1.png",
"events": [
{
"id": 100,
"name": "Letní trhy",
"description": "Prodej ovoce, zeleniny a domácích výrobků.",
"start": "2025-08-01T08:00:00.000Z",
"end": "2025-08-01T16:00:00.000Z",
"price_per_m2": "420",
"image": "/img/namest-1.png",
"market_slots": [
{
"id": 1001,
"event": 100,
"status": "empty",
"base_size": 6,
"available_extension": 2,
"x": 100,
"y": 100,
"width": 300,
"height": 300,
"price_per_m2": "420"
}
]
}
]
},
{
"id": 5,
"name": "Staroměstská tržnice",
"description": "Historické tržiště s tradičními řemeslnými stánky.",
"street": "Náměstí 1",
"city": "Praha",
"psc": 11000,
"width": 6000,
"height": 3500,
"grid_rows": 60,
"grid_cols": 35,
"cellsize": 100,
"image": "/img/namest-1.png",
"events": [
{
"id": 101,
"name": "Řemeslný den",
"description": "Ukázky tradičních řemesel a rukodělných výrobků.",
"start": "2025-09-10T10:00:00.000Z",
"end": "2025-09-10T18:00:00.000Z",
"price_per_m2": "500",
"image": "/img/namest-1.png",
"market_slots": [
{
"id": 1002,
"event": 101,
"status": "occupied",
"base_size": 8,
"available_extension": 3,
"x": 200,
"y": 150,
"width": 400,
"height": 300,
"price_per_m2": "500"
},
{
"id": 1003,
"event": 101,
"status": "empty",
"base_size": 6,
"available_extension": 1,
"x": 650,
"y": 150,
"width": 300,
"height": 300,
"price_per_m2": "500"
}
]
}
]
},
{
"id": 6,
"name": "Tržiště Sever",
"description": "Nové moderní tržiště v severní části města.",
"street": "Severní 99",
"city": "Ostrava",
"psc": 70030,
"width": 4000,
"height": 2800,
"grid_rows": 40,
"grid_cols": 28,
"cellsize": 100,
"image": "/img/namest-1.png",
"events": [
{
"id": 102,
"name": "Festival chutí",
"description": "Ochutnávky světových kuchyní a místních specialit.",
"start": "2025-07-25T12:00:00.000Z",
"end": "2025-07-25T22:00:00.000Z",
"price_per_m2": "620",
"image": "/img/namest-1.png",
"market_slots": [
{
"id": 1004,
"event": 102,
"status": "reserved",
"base_size": 10,
"available_extension": 5,
"x": 300,
"y": 200,
"width": 500,
"height": 300,
"price_per_m2": "620"
}
]
}
]
},
{
"id": 7,
"name": "Náměstí svobody",
"description": "Centrální náměstí s možností konání kulturních akcí.",
"street": "Svobody 1",
"city": "Zlín",
"psc": 76001,
"width": 5500,
"height": 3300,
"grid_rows": 55,
"grid_cols": 33,
"cellsize": 100,
"image": "/img/namest-1.png",
"events": [
{
"id": 103,
"name": "Letní slavnosti",
"description": "Hudební program, stánky a večerní ohňostroj.",
"start": "2025-08-10T15:00:00.000Z",
"end": "2025-08-10T23:00:00.000Z",
"price_per_m2": "700",
"image": "/img/namest-1.png",
"market_slots": [
{
"id": 1005,
"event": 103,
"status": "empty",
"base_size": 7,
"available_extension": 2,
"x": 150,
"y": 300,
"width": 350,
"height": 300,
"price_per_m2": "700"
}
]
}
]
},
{
"id": 8,
"name": "Městská tržnice",
"description": "Tradiční městské tržiště s krytým i venkovním prostorem.",
"street": "Masarykova 88",
"city": "Plzeň",
"psc": 30100,
"width": 4800,
"height": 2900,
"grid_rows": 48,
"grid_cols": 29,
"cellsize": 100,
"image": "/img/namest-1.png",
"events": [
{
"id": 104,
"name": "Plzeňský trh",
"description": "Speciální nabídka pivních specialit a suvenýrů.",
"start": "2025-08-28T10:00:00.000Z",
"end": "2025-08-28T20:00:00.000Z",
"price_per_m2": "560",
"image": "/img/namest-1.png",
"market_slots": [
{
"id": 1006,
"event": 104,
"status": "occupied",
"base_size": 9,
"available_extension": 3,
"x": 500,
"y": 400,
"width": 450,
"height": 300,
"price_per_m2": "560"
}
]
}
]
}
]

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,114 @@
import React, { useState } from 'react';
import { Container, Button, Alert, Spinner } from 'react-bootstrap';
import { Link } from 'react-router-dom'; // Umožňuje odkazování v rámci SPA
const backendURL = import.meta.env.VITE_BACKEND_URL; //import url backendu
// Komponenta pro ověření e-mailu
function EmailVerificationPage() {
// Stavy komponenty:
// - idle: čeká se na kliknutí
// - loading: probíhá požadavek
// - success: ověření proběhlo úspěšně
// - error: něco se pokazilo
const [status, setStatus] = useState('idle');
const [errorMsg, setErrorMsg] = useState(null);
// Načtení parametrů z URL (např. ?uidb64=abc&token=xyz)
const searchParams = new URLSearchParams(window.location.search);
const uidb64 = searchParams.get('uidb64');
const token = searchParams.get('token');
// Funkce spuštěná po kliknutí na tlačítko "Verifikovat"
const handleVerify = async () => {
// Zkontroluj, zda v URL jsou potřebné parametry
if (!uidb64 || !token) {
setErrorMsg('Chybí potřebné parametry v URL.');
setStatus('error');
return;
}
// Zobrazíme loading stav
setStatus('loading');
setErrorMsg(null);
try {
// Sestavíme URL pro API volání
const url = `${backendURL}/api/account/registration/verify-email/${encodeURIComponent(uidb64)}/${encodeURIComponent(token)}`;
// Pošleme GET požadavek na backend
const response = await fetch(url, { method: 'GET' });
// Pokud vše proběhlo OK
if (response.ok) {
setStatus('success');
} else {
// Jinak zobrazíme chybovou zprávu od backendu
const data = await response.json();
setErrorMsg(data.detail || 'Ověření selhalo.');
setStatus('error');
}
} catch (err) {
// Chyba při spojení se serverem
setErrorMsg('Chyba při spojení se serverem.');
setStatus('error');
}
};
return (
<Container className="my-5 p-4 shadow-sm bg-light border rounded" style={{ maxWidth: 500 }}>
<h2 className="mb-4 text-center">Ověření e-mailu</h2>
{/* Výchozí stav: uživatel může kliknout na tlačítko */}
{status === 'idle' && (
<>
<p className="text-center">Kliknutím ověříš svůj e-mailový účet.</p>
<div className="d-flex justify-content-center">
<Button variant="primary" onClick={handleVerify}>
Verifikovat
</Button>
</div>
</>
)}
{/* Stav: načítání zobrazí spinner */}
{status === 'loading' && (
<div className="text-center d-flex justify-content-center">
<Spinner animation="border" role="status" />
<p className="mt-3">Probíhá ověřování...</p>
</div>
)}
{/* Stav: úspěšné ověření zobrazí success hlášku a tlačítko na přihlášení */}
{status === 'success' && (
<>
<Alert variant="success" className="text-center">
E-mail byl úspěšně ověřen!
</Alert>
<div className="mt-3 d-flex justify-content-center">
{/* Odkaz na přihlášení používá react-router */}
<Button as={Link} to="/" variant="success">
Přihlásit se
</Button>
</div>
</>
)}
{/* Stav: chyba zobrazí error hlášku a možnost zkusit znovu */}
{status === 'error' && (
<>
<Alert variant="danger" className="text-center">
Chyba: {errorMsg}
</Alert>
<div className="d-grid">
<Button variant="danger" onClick={handleVerify}>
Zkusit znovu
</Button>
</div>
</>
)}
</Container>
);
}
export default EmailVerificationPage;

View File

@@ -0,0 +1,525 @@
// Exported default config for use in other components
export const DEFAULT_CONFIG = {
rows: 28,
cols: 20,
cellSize: 30,
statusColors: {
allowed: "rgba(0, 128, 0, 0.6)",
taken: "rgba(255, 165, 0, 0.6)",
blocked: "rgba(255, 0, 0, 0.6)",
},
};
// DynamicGrid.jsx
// This component renders a dynamic grid for managing reservations.
import React, {
useState,
useRef,
useCallback,
useMemo,
useEffect,
} from "react";
const DynamicGrid = ({
config = DEFAULT_CONFIG,
reservations,
onReservationsChange,
selectedIndex,
onSelectedIndexChange,
static: isStatic = false, //možnost editovaní prostorů
multiSelect = false, //možnost zvolit více rezervací
clickableStatic = false, //možnost volit rezervace i ve ,,static,, = true
backgroundImage, // <-- add this prop
}) => {
const {
rows = DEFAULT_CONFIG.rows,
cols = DEFAULT_CONFIG.cols,
cellSize = DEFAULT_CONFIG.cellSize,
statusColors = DEFAULT_CONFIG.statusColors,
} = config;
const statusLabels = {
allowed: "Povoleno",
taken: "Rezervováno",
blocked: "Blokováno",
};
const [startCell, setStartCell] = useState(null);
const [hoverCell, setHoverCell] = useState(null);
const [isDragging, setIsDragging] = useState(false);
const [draggedIndex, setDraggedIndex] = useState(null);
const [resizingIndex, setResizingIndex] = useState(null);
const gridRef = useRef(null);
const dragOffsetRef = useRef({ x: 0, y: 0 });
const lastCoordsRef = useRef(null);
// Selection is now fully controlled by parent
// Selection is now fully controlled by parent
const getSelectedIndices = () => {
if (multiSelect) {
return Array.isArray(selectedIndex) ? selectedIndex : [];
} else {
return selectedIndex !== null && selectedIndex !== undefined ? [selectedIndex] : [];
}
};
const selectedIndices = getSelectedIndices();
// Selection is now fully controlled by parent
// Clamp function to ensure values stay within bounds
// This function restricts a value to be within a specified range.
const clamp = useCallback(
(val, min, max) => Math.max(min, Math.min(max, val)),
[]
);
// Function to get cell coordinates based on mouse event
// This function calculates the grid cell coordinates based on the mouse position.
const getCellCoords = useCallback(
(e) => {
const rect = gridRef.current.getBoundingClientRect();
const cellWidth = rect.width / cols;
const cellHeight = rect.height / rows;
const x = clamp(Math.floor((e.clientX - rect.left) / cellWidth), 0, cols - 1);
const y = clamp(Math.floor((e.clientY - rect.top) / cellHeight), 0, rows - 1);
return { x, y };
},
[clamp, rows, cols]
);
// Function to check if two rectangles overlap
// This function determines if two rectangles defined by their coordinates overlap.
const rectanglesOverlap = useCallback(
(a, b) =>
a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y,
[]
);
// Function to check if a new rectangle collides with existing reservations
// This function checks if a new rectangle overlaps with any existing reservations,
const hasCollision = useCallback(
(newRect, ignoreIndex = -1) =>
reservations.some(
(r, i) => i !== ignoreIndex && rectanglesOverlap(newRect, r)
),
[reservations, rectanglesOverlap]
);
// Function to handle mouse down events
// This function initiates dragging or resizing of reservations based on mouse events.
const handleMouseDown = useCallback(
(e) => {
if (e.button !== 0) return;
lastCoordsRef.current = null;
const coords = getCellCoords(e);
let isReservationClicked = false;
const resIndex = reservations.findIndex(
(r) =>
coords.x >= r.x &&
coords.x < r.x + r.w &&
coords.y >= r.y &&
coords.y < r.y + r.h
);
if (resIndex !== -1) {
const res = reservations[resIndex];
isReservationClicked = true;
if (!isStatic || res.status === "allowed") {
if (multiSelect) {
let newSelected;
if (selectedIndices.includes(resIndex)) {
newSelected = selectedIndices.filter(i => i !== resIndex);
} else {
newSelected = [...selectedIndices, resIndex];
}
// setSelectedIndices removed; selection is now controlled by parent
onSelectedIndexChange(newSelected);
} else {
// setSelectedIndices removed; selection is now controlled by parent
onSelectedIndexChange(resIndex);
}
}
if (!isStatic) {
dragOffsetRef.current = {
x: coords.x - res.x,
y: coords.y - res.y,
};
if (e.target.classList.contains("resize-handle")) {
setResizingIndex(resIndex);
setDraggedIndex(null);
} else {
setDraggedIndex(resIndex);
setResizingIndex(null);
}
}
} else if (!isStatic) {
setStartCell(coords);
setIsDragging(true);
setDraggedIndex(null);
setResizingIndex(null);
}
// Deselect if clicking outside any reservation
if (!isReservationClicked) {
onSelectedIndexChange(null);
}
},
[
getCellCoords,
reservations,
isStatic,
multiSelect,
selectedIndices,
onSelectedIndexChange,
// setSelectedIndices removed; selection is now controlled by parent
dragOffsetRef,
setDraggedIndex,
setResizingIndex,
setStartCell,
setIsDragging,
]
);
// Function to handle mouse move events
// This function updates the hover cell and handles dragging/resizing.
const handleMouseMove = useCallback(
(e) => {
if (isStatic) return;
const coords = getCellCoords(e);
if (isDragging && startCell) {
setHoverCell(coords);
}
if (draggedIndex !== null) {
const res = reservations[draggedIndex];
const offset = dragOffsetRef.current;
const newX = clamp(coords.x - offset.x, 0, cols - res.w);
const newY = clamp(coords.y - offset.y, 0, rows - res.h);
if (
!hasCollision(
{ ...res, x: newX, y: newY },
draggedIndex
)
) {
onReservationsChange((prev) =>
prev.map((r, i) =>
i === draggedIndex ? { ...r, x: newX, y: newY } : r
)
);
}
}
if (resizingIndex !== null) {
const res = reservations[resizingIndex];
const minW = 1;
const minH = 1;
const newW = clamp(coords.x - res.x + 1, minW, cols - res.x);
const newH = clamp(coords.y - res.y + 1, minH, rows - res.y);
if (
!hasCollision(
{ ...res, w: newW, h: newH },
resizingIndex
)
) {
onReservationsChange((prev) =>
prev.map((r, i) =>
i === resizingIndex ? { ...r, w: newW, h: newH } : r
)
);
}
}
},
[
isStatic,
isDragging,
startCell,
getCellCoords,
setHoverCell,
draggedIndex,
reservations,
dragOffsetRef,
clamp,
cols,
rows,
hasCollision,
onReservationsChange,
resizingIndex,
setResizingIndex,
]
);
// Function to handle mouse up events
// This function finalizes the creation of a new reservation or ends dragging/resizing.
const handleMouseUp = useCallback(
(e) => {
if (isStatic) return;
if (isDragging && startCell && hoverCell) {
const minX = Math.min(startCell.x, hoverCell.x);
const minY = Math.min(startCell.y, hoverCell.y);
const w = Math.abs(startCell.x - hoverCell.x) + 1;
const h = Math.abs(startCell.y - hoverCell.y) + 1;
const newRect = {
x: minX,
y: minY,
w,
h,
name: `Cell ${reservations.length + 1}`,
status: "allowed",
};
if (
!hasCollision(newRect) &&
minX >= 0 &&
minY >= 0 &&
minX + w <= cols &&
minY + h <= rows
) {
onReservationsChange((prev) => [...prev, newRect]);
}
}
setStartCell(null);
setHoverCell(null);
setIsDragging(false);
setDraggedIndex(null);
setResizingIndex(null);
lastCoordsRef.current = null;
},
[
isDragging,
startCell,
hoverCell,
reservations,
hasCollision,
onReservationsChange,
rows,
cols,
isStatic,
]
);
// Function to handle reservation deletion
// This function removes a reservation from the grid based on its index.
const handleDeleteReservation = useCallback(
(index) => {
if (isStatic) return; // Disable for static
onReservationsChange((prev) => prev.filter((_, i) => i !== index));
// Aktualizuj vybraný stav
if (multiSelect) {
const newSelected = selectedIndices.filter(i => i !== index);
// setSelectedIndices removed; selection is now controlled by parent
onSelectedIndexChange(newSelected);
} else {
if (selectedIndex === index) {
onSelectedIndexChange(null);
} else if (selectedIndex > index) {
onSelectedIndexChange(selectedIndex - 1);
}
}
},
[onReservationsChange, onSelectedIndexChange, selectedIndex, selectedIndices, multiSelect, isStatic]
);
// Function to handle status change of a reservation
// This function updates the status of a reservation based on user selection.
const handleStatusChange = useCallback(
(index, newStatus) => {
if (isStatic) return; // Disable for static
onReservationsChange((prev) =>
prev.map((res, i) =>
i === index ? { ...res, status: newStatus } : res
)
);
},
[onReservationsChange, isStatic]
);
// Generate grid cells based on rows and columns
// This function creates a grid of cells based on the specified number of rows and columns.
const gridCells = useMemo(
() =>
[...Array(rows * cols)].map((_, index) => {
const x = index % cols;
const y = Math.floor(index / cols);
return (
<div
key={`${x}-${y}`}
className="cell border"
style={{
gridColumn: x + 1,
gridRow: y + 1,
backgroundColor: "transparent",
border: "1px solid #ccc",
opacity: 0.3,
boxSizing: "border-box",
pointerEvents: "none",
width: "100%",
height: "100%",
}}
/>
);
}),
[rows, cols, cellSize]
);
// Filter out-of-bounds reservations and keep mapping to original indices
const filteredReservationsWithIndex = reservations
.map((res, idx) => ({ ...res, _originalIndex: idx }))
.filter(
(res) =>
res.x >= 0 && res.y >= 0 &&
res.x + res.w <= cols &&
res.y + res.h <= rows
);
return (
<div
ref={gridRef}
className="position-relative h-100 rounded grid-bg"
onMouseDown={handleMouseDown}
onMouseMove={isStatic ? undefined : handleMouseMove}
onMouseUp={isStatic ? undefined : handleMouseUp}
onMouseLeave={isStatic ? undefined : handleMouseUp}
onContextMenu={(e) => (isStatic ? undefined : e.preventDefault())}
style={{
width: "100%",
height: "auto",
aspectRatio: `${cols} / ${rows}`,
display: "grid",
gridTemplateColumns: `repeat(${cols}, 1fr)`,
gridTemplateRows: `repeat(${rows}, 1fr)`,
cursor: isStatic ? "default" : "crosshair",
position: "relative",
boxSizing: "border-box",
userSelect: "none",
backgroundImage: backgroundImage ? `url(${backgroundImage})` : undefined,
backgroundSize: "contain",
backgroundRepeat: "no-repeat",
backgroundPosition: "center",
}}
>
{/* Grid buňky (pozadí) */}
{gridCells.map((cell, i) => React.cloneElement(cell, { key: i }))}
{/* Rezervace */}
{filteredReservationsWithIndex.map((res, i) => {
const origIdx = res._originalIndex;
return (
<div
key={origIdx}
data-index={origIdx}
className={`reservation ${res.status} ${draggedIndex === origIdx ? "dragging" : ""}`}
onContextMenu={(e) => {
if (!isStatic) {
e.preventDefault();
handleDeleteReservation(origIdx);
}
}}
style={{
position: "absolute",
left: (res.x / cols) * 100 + "%",
top: (res.y / rows) * 100 + "%",
width: (res.w / cols) * 100 + "%",
height: (res.h / rows) * 100 + "%",
backgroundColor: statusColors[res.status],
border: selectedIndices.includes(origIdx) ? "2px solid black" : "none",
boxShadow: selectedIndices.includes(origIdx) ? "0 0 8px 2px rgba(0,0,0,0.3)" : "none",
borderRadius: 4,
fontSize: "0.8rem",
textAlign: "center",
transition: draggedIndex === origIdx || resizingIndex === origIdx ? "none" : "all 0.2s ease",
zIndex: 2,
cursor: isStatic ? (res.status === "allowed" ? "pointer" : "default") : "move",
overflow: "hidden",
userSelect: "none",
}}
onClick={(e) => {
e.stopPropagation();
if (!isStatic || (clickableStatic && res.status === "allowed")) {
// Always notify parent of clicked index; parent manages selection array
onSelectedIndexChange(origIdx);
}
}}
>
<div className="d-flex flex-column h-100 p-1 text-white">
<div className="flex-grow-1 d-flex align-items-center justify-content-center">
<strong>{i + 1}</strong>
</div>
{isStatic ? (
<div className="text-center">
{statusLabels[res.status]}
</div>
) : (
<select
className="form-select form-select-sm"
value={res.status}
onChange={(e) => handleStatusChange(origIdx, e.target.value)}
onClick={(e) => e.stopPropagation()}
>
<option value="allowed">Povolené</option>
<option value="blocked">Blokováno</option>
</select>
)}
</div>
{!isStatic && (
<div
className="resize-handle"
style={{
position: "absolute",
right: 0,
bottom: 0,
width: "1rem",
height: "1rem",
backgroundColor: "black",
cursor: "nwse-resize",
zIndex: 3,
border: "1px solid white",
borderRadius: "0 0 4px 0",
boxSizing: "border-box",
}}
/>
)}
</div>
);
})}
{/* Výběr nové rezervace (draft) */}
{!isStatic && isDragging && startCell && hoverCell && (
<div
className="reservation-draft"
style={{
position: "absolute",
left: (Math.min(startCell.x, hoverCell.x) / cols) * 100 + "%",
top: (Math.min(startCell.y, hoverCell.y) / rows) * 100 + "%",
width: ((Math.abs(startCell.x - hoverCell.x) + 1) / cols) * 100 + "%",
height: ((Math.abs(startCell.y - hoverCell.y) + 1) / rows) * 100 + "%",
backgroundColor: "rgba(0, 128, 0, 0.3)",
pointerEvents: "none",
zIndex: 1,
border: "2px dashed rgba(0, 128, 0, 0.7)",
borderRadius: 4,
}}
/>
)}
</div>
);
};
export default DynamicGrid;

View File

@@ -0,0 +1,155 @@
import React, { useState } from "react";
import { Responsive, WidthProvider } from "react-grid-layout";
import "react-grid-layout/css/styles.css";
import "react-resizable/css/styles.css";
const ResponsiveGridLayout = WidthProvider(Responsive);
const MyResponsiveGrid = () => {
const maxGridHeight = 700; // Your fixed height
const rowHeight = 30; // Should match your rowHeight prop
const maxRows = Math.floor(maxGridHeight / rowHeight) - 6;
const [layoutData, setLayoutData] = useState([]);
const [lockMode, setLockMode] = useState(false);
const cleanLayout = (layout) => {
return layout.map(item => {
if (item.y + item.h > maxRows) {
return { ...item, y: Math.max(0, maxRows - item.h) };
}
return item;
});
};
const handleLayoutChange = (currentLayout) => {
const filtered = currentLayout.filter(item => item.i !== "__dropping-elem__");
const cleaned = cleanLayout(filtered);
setLayoutData(cleaned);
};
const handleDrop = (layout, layoutItem, _event) => {
const newItemId = new Date().getTime().toString();
const { w = 2, h = 2, status } = JSON.parse(_event.dataTransfer.getData("text/plain")) || {};
// Prevent dropping below max rows
if (layoutItem.y + h > maxRows) {
layoutItem.y = Math.max(0, maxRows - h);
}
const newItem = {
i: newItemId,
x: layoutItem.x,
y: layoutItem.y,
w,
h,
static: status === "reserved" || status === "blocked",
status,
};
setLayoutData((prev) => [...prev, newItem]);
};
const toggleLockMode = () => setLockMode(prev => !prev);
const handleItemClick = (id) => {
if (!lockMode) return;
setLayoutData(prev =>
prev.map(item =>
item.i === id ? { ...item, static: !item.static } : item
)
);
};
const getItemClass = (item) => {
if (item.status === "reserved") return "bg-warning";
if (item.status === "blocked") return "bg-danger";
return "bg-white";
};
return (
<div className="p-4 flex gap-4">
<div className="flex flex-col gap-2">
<button
onClick={toggleLockMode}
className="mb-2 px-4 py-2 bg-blue-600 text-white rounded"
>
{lockMode ? "Exit Lock Mode" : "Enter Lock Mode"}
</button>
<div
className="droppable-element"
draggable={true}
unselectable="on"
onDragStart={(e) => {
e.dataTransfer.setData(
"text/plain",
JSON.stringify({ w: 2, h: 2 })
);
}}
>
Free
</div>
<div
className="droppable-element bg-warning"
draggable={true}
unselectable="on"
onDragStart={(e) => {
e.dataTransfer.setData(
"text/plain",
JSON.stringify({ w: 3, h: 2, status: "reserved" })
);
}}
>
Reserved Block
</div>
<div
className="droppable-element bg-danger"
draggable={true}
unselectable="on"
onDragStart={(e) => {
e.dataTransfer.setData(
"text/plain",
JSON.stringify({ w: 3, h: 2, status: "blocked" })
);
}}
>
Blocked Block
</div>
</div>
<ResponsiveGridLayout
style={{ width: "800px", height: `${maxGridHeight}px`, overflow: "hidden" }}
className="layout bg-secondary"
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
cols={{ lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 }}
rowHeight={rowHeight}
width={1200}
autoSize={false}
maxRows={maxRows} // This prevents visual overflow
useCSSTransforms={true}
onLayoutChange={handleLayoutChange}
isDroppable={true}
onDrop={handleDrop}
layout={layoutData}
>
{layoutData.map((item) => (
<div
key={item.i}
data-grid={item}
onClick={() => handleItemClick(item.i)}
className={`${getItemClass(item)} p-2 border rounded text-center cursor-pointer`}
>
Item {item.i} {item.static ? "(Locked)" : ""}
</div>
))}
</ResponsiveGridLayout>
<pre className="mt-4 text-sm text-dark">
{JSON.stringify(layoutData, null, 2)}
</pre>
</div>
);
};
export default MyResponsiveGrid;

View File

@@ -0,0 +1,175 @@
import Button from "react-bootstrap/Button";
import Form from "react-bootstrap/Form";
import Card from "react-bootstrap/Card";
import Row from "react-bootstrap/Row";
import Col from "react-bootstrap/Col";
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faEnvelope } from '@fortawesome/free-solid-svg-icons';
import { faKey } from '@fortawesome/free-solid-svg-icons';
import { useState } from "react";
import Spinner from "react-bootstrap/Spinner";
import { login } from "../api/auth";
import { useNavigate } from "react-router-dom";
function LoginCard() {
const navigate = useNavigate();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [errorMessage, setErrorMessage] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
setErrorMessage("");
try {
const success = await login(email, password);
if (success) {
navigate("/home");
console.log("Přihlášení bylo úspěšné");
}
} catch (error) {
console.error("Chyba při přihlášení:", error);
// Rozlišení typu chyby
if (error.response) {
if (error.response.status === 0 || error.response.status >= 500) {
setErrorMessage("Chyba sítě nebo serveru. Zkuste to později.");
} else if (error.response.status === 401 || error.response.status === 400) {
setErrorMessage("Neplatné přihlašovací údaje.");
} else {
setErrorMessage("Neočekávaná chyba při přihlášení.");
}
} else if (error.request) {
setErrorMessage("Nelze se spojit se serverem. Zkontrolujte připojení k internetu.");
} else {
setErrorMessage("Chyba aplikace: " + error.message);
}
} finally {
setLoading(false);
}
};
return (
<Card className="align-self-center">
<Card.Header className="text-center m-auto p-0">
<h2 className="text-white pt-3">Přihlášení</h2>
</Card.Header>
<Card.Body className="pt-3">
<Form onSubmit={handleSubmit}>
<Form.Group
className="input-group form-group"
controlId="formBasicEmail"
>
<Form.Group className="input-group-prepend">
<span className="input-group-text">
<FontAwesomeIcon icon={faEnvelope} />
</span>
</Form.Group>
<Form.Label hidden>Email address</Form.Label>
<Form.Control
type="email"
placeholder="E-mail"
required
autoComplete="email"
autoFocus
name="email"
onChange={(e) => setEmail(e.target.value)}
value={email}
disabled={loading}
/>
</Form.Group>
<Form.Group
className="input-group form-group"
controlId="formBasicPassword"
>
<Form.Group className="input-group-prepend">
<span className="input-group-text">
<FontAwesomeIcon icon={faKey} />
</span>
</Form.Group>
<Form.Label hidden>Password</Form.Label>
<Form.Control
type="password"
placeholder="Heslo"
required
autoComplete="current-password"
name="loginPassword"
onChange={(e) => setPassword(e.target.value)}
value={password}
disabled={loading}
/>
</Form.Group>
<Form.Group className=" m-auto text-align-center form-group">
<Row className="align-items-center px-3">
<Col className="">
<div className="custom-control custom-checkbox">
<input
className="custom-control-input"
type="checkbox"
name="remember"
id="remember"
disabled={loading}
/>
<label className="custom-control-label m-auto" htmlFor="remember">
Zapamatovat si
</label>
</div>
</Col>
<Col>
<Button type="submit" className="float-right login_btn" disabled={loading}>
{loading && (
<Spinner
as="span"
animation="border"
size="sm"
role="status"
aria-hidden="true"
className="me-2"
/>
)}
Přihlášení
</Button>
</Col>
</Row>
</Form.Group>
<Form.Group className="form-group">
{/* Zobrazení chyby */}
{errorMessage && (
<div className="mt-2 text-danger">
{errorMessage}
</div>
)}
</Form.Group>
</Form>
</Card.Body>
<Card.Footer>
<Row>
<Col className="links">
<Button href="/register" variant="success" className="float-right text-white" disabled={loading}>
Vytvořit účet stánkaře
</Button>
<div className="pt-1">
<a href="/reset-password">
Zapomenuté heslo?
</a>
</div>
</Col>
</Row>
</Card.Footer>
</Card>
);
}
export default LoginCard;

View File

@@ -0,0 +1,71 @@
import React, { useContext, useEffect, useState } from 'react';
import { Container, Nav, Navbar } from 'react-bootstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faRightFromBracket, faUser, faTicket } from '@fortawesome/free-solid-svg-icons';
import { Link, useNavigate } from 'react-router-dom';
import staticLogo from '/img/logo.png';
import { logout } from '../api/auth';
import { UserContext } from '../context/UserContext';
import { getPublicAppConfig } from '../api/model/Settings';
function NavBar() {
const { user, setUser } = useContext(UserContext);
const navigate = useNavigate();
const [logoUrl, setLogoUrl] = useState(staticLogo);
useEffect(() => {
(async () => {
const data = await getPublicAppConfig(['logo']);
if (data?.logo) setLogoUrl(data.logo);
})();
}, []);
const handleLogout = async () => {
try {
await logout();
setUser(null);
navigate('/login');
} catch (err) {
console.error('Logout failed', err);
}
};
return (
<Navbar expand="lg">
<Container>
<Navbar.Brand as={Link} to="/home" className="d-flex align-items-center">
<img src={logoUrl} className="d-none d-sm-block" alt="Logo" style={{maxHeight:48, width:'auto'}} />
</Navbar.Brand>
<Navbar.Toggle aria-controls="basic-navbar-nav" />
<Navbar.Collapse id="basic-navbar-nav">
<Nav className="ms-auto text-uppercase ml-auto">
{user ? (
<>
<Nav.Link as={Link} to="/tickets">
<FontAwesomeIcon icon={faTicket} className="mr-2" />
Tikety
</Nav.Link>
<div className="vr mx-2" style={{ width: '2px', background: '#003a6b' }} />
<Nav.Link as={Link} to="/home">
<FontAwesomeIcon icon={faUser} className="mr-2" />
{user.username}
</Nav.Link>
<Nav.Link onClick={handleLogout} style={{ cursor: 'pointer' }}>
<FontAwesomeIcon icon={faRightFromBracket} />
</Nav.Link>
</>
) : (
<>
<Nav.Link as={Link} to="/login">Přihlásit se</Nav.Link>
<Nav.Link as={Link} to="/register">Registrace</Nav.Link>
</>
)}
</Nav>
</Navbar.Collapse>
</Container>
</Navbar>
);
}
export default NavBar;

View File

@@ -0,0 +1,480 @@
import { Button, Form, Card, Row, Col, ToggleButton, Container, InputGroup, Modal } from "react-bootstrap";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faUser,
faUniversity,
faKey,
faBuilding,
faPhone,
faEnvelope,
faLock,
faBook,
faAddressCard,
faBriefcase,
faRoad,
faEnvelopeSquare,
} from "@fortawesome/free-solid-svg-icons";
import React, { use, useState } from "react";
import { useNavigate } from "react-router-dom";
import { API_URL } from "../api/auth";
function RegisterCard() {
const [isFirm, setIsFirm] = useState(false); // false = Individual, true = Company
const handleSwitchChange = (e) => {
setIsFirm(!isFirm);
setAccountType(!isFirm ? "Company" : "Individual");
};
const [error, setError] = useState("");
const navigate = useNavigate();
const [show, setShow] = useState(false);
const handleClose = () => setShow(false);
const handleShow = () => setShow(true);
const [first_name, setFirstName] = useState("");
const [last_name, setLastName] = useState("");
const [email, setEmail] = useState("@");
const [password, setPassword] = useState("");
const [phone_number, setPhoneNumber] = useState("+420");
const [street, setStreet] = useState("");
const [city, setCity] = useState("");
const [PSC, setPSC] = useState("");
const [bank_account, setBankAccount] = useState("");
const [ICO, setICO] = useState("");
const [RC, setRC] = useState("");
const [GDPR, setGDPR] = useState(true);
const [account_type, setAccountType] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
if (isSubmitting) return; // ⛔ Prevent multiple submits
setIsSubmitting(true);
setError("");
try {
const response = await fetch(`${API_URL}/account/registration/`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
first_name,
last_name,
email,
phone_number,
street,
city,
PSC,
bank_account,
RC,
ICO,
GDPR,
account_type,
password,
}),
});
if (!response.ok) {
throw new Error("Neplatné přihlašovací údaje");
}
// přesměruj na dashboard nebo domovskou stránku
navigate("/reservation");
} catch (err) {
setIsSubmitting(false);
navigate("/register");
setError(err.message || "Přihlášení selhalo");
console.log(error);
}
};
return (
<>
<Card>
<Card.Header className="form-top">
<div className="form-top-left">
<h3>Registrační formulář</h3>
<p>Vyplňte níže požadované údaje</p>
</div>
<div className="form-top-right">
<FontAwesomeIcon icon={faLock} />
</div>
</Card.Header>
<Card.Body className="form-bottom">
<Form onSubmit={handleSubmit}>
<Form.Check
type="switch"
label={isFirm ? "Firma" : "Občan"}
onChange={handleSwitchChange}
checked={isFirm}
className="mb-2"
/>
{isFirm ? (
<>
<Form.Group className="input-group form-group">
<Form.Label hidden>Název</Form.Label>
<InputGroup>
<div className="input-group-prepend">
<InputGroup.Text className="isize">
<FontAwesomeIcon icon={faBriefcase} />
&nbsp; Název
</InputGroup.Text>
</div>
<Form.Control
type="text"
placeholder=""
aria-label="first_name"
name="first_name"
required
value={first_name}
onChange={(e) => setFirstName(e.target.value)}
/>
</InputGroup>
</Form.Group>
</>
) : (
<>
<Form.Group className="input-group form-group">
<Form.Label hidden>First Name</Form.Label>
<InputGroup>
<div className="input-group-prepend">
<InputGroup.Text className="isize">
<FontAwesomeIcon icon={faUser} />
&nbsp; Jméno
</InputGroup.Text>
</div>
<Form.Control
type="text"
placeholder=""
aria-label="first_name"
name="first_name"
value={first_name}
onChange={(e) => setFirstName(e.target.value)}
/>
</InputGroup>
</Form.Group>
<Form.Group className="input-group form-group">
<Form.Label hidden>Last Name</Form.Label>
<InputGroup>
<div className="input-group-prepend">
<InputGroup.Text className="isize">
<FontAwesomeIcon icon={faUser} />
&nbsp; Příjmení
</InputGroup.Text>
</div>
<Form.Control
type="text"
placeholder=""
aria-label="last_name"
name="last_name"
value={last_name}
onChange={(e) => setLastName(e.target.value)}
/>
</InputGroup>
</Form.Group>
</>
)}
<Form.Group className="input-group form-group">
<Form.Label hidden>Email</Form.Label>
<InputGroup>
<div className="input-group-prepend">
<InputGroup.Text className="isize">
<FontAwesomeIcon icon={faEnvelope} />
&nbsp; Email
</InputGroup.Text>
</div>
<Form.Control
type="email"
placeholder=""
aria-label="email"
name="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</InputGroup>
</Form.Group>
<Form.Group className="input-group form-group">
<Form.Label hidden>Password</Form.Label>
<InputGroup>
<div className="input-group-prepend">
<InputGroup.Text className="isize">
<FontAwesomeIcon icon={faKey} />
&nbsp; Heslo
</InputGroup.Text>
</div>
<Form.Control
type="password"
placeholder=""
aria-label="password"
name="password"
pattern="^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</InputGroup>
</Form.Group>
<Form.Group className="input-group form-group">
<Form.Label hidden>Telefon</Form.Label>
<InputGroup>
<div className="input-group-prepend">
<InputGroup.Text className="isize">
<FontAwesomeIcon icon={faPhone} />
&nbsp; Telefon
</InputGroup.Text>
</div>
<Form.Control
type="text"
placeholder=""
aria-label="phone_number"
name="phone_number"
required
value={phone_number}
onChange={(e) => setPhoneNumber(e.target.value)}
/>
</InputGroup>
</Form.Group>
<Form.Group className="input-group form-group">
<Form.Label hidden>Ulice</Form.Label>
<InputGroup>
<div className="input-group-prepend">
<InputGroup.Text className="isize">
<FontAwesomeIcon icon={faRoad} />
&nbsp; Ulice
</InputGroup.Text>
</div>
<Form.Control
type="text"
placeholder=""
aria-label="street"
name="street"
required
value={street}
onChange={(e) => setStreet(e.target.value)}
/>
</InputGroup>
</Form.Group>
<Form.Group className="row">
<Col xs={12} md={6} className="mb-3">
<Form.Label hidden>Město</Form.Label>
<InputGroup className="flex-nowrap">
<InputGroup.Text className="isize">
<FontAwesomeIcon icon={faBuilding} />
&nbsp; Město
</InputGroup.Text>
<Form.Control
type="text"
placeholder=""
aria-label="city"
name="city"
required
value={city}
onChange={(e) => setCity(e.target.value)}
style={{ minWidth: 0 }} // klíčové pro rozbití šířky
/>
</InputGroup>
</Col>
<Col xs={12} md={6} className="mb-3">
<Form.Label hidden>PSČ</Form.Label>
<InputGroup className="flex-nowrap">
<InputGroup.Text className="isize">
<FontAwesomeIcon icon={faEnvelopeSquare} />
&nbsp; PSČ
</InputGroup.Text>
<Form.Control
type="text"
placeholder=""
aria-label="PSC"
name="PSC"
required
value={PSC}
onChange={(e) => setPSC(e.target.value)}
style={{ minWidth: 0 }}
/>
</InputGroup>
</Col>
</Form.Group>
<Form.Group className="input-group form-group">
<Form.Label hidden>Bank account number</Form.Label>
<InputGroup>
<div className="input-group-prepend">
<InputGroup.Text className="isize">
<FontAwesomeIcon icon={faUniversity} />
&nbsp; Číslo účtu
</InputGroup.Text>
</div>
<Form.Control
type="text"
placeholder=""
aria-label="bank_account"
name="bank_account"
required
inputMode="numeric"
pattern="^(\d{0,6}-)?\d{1,10}/\d{4}$"
value={bank_account}
onChange={(e) => setBankAccount(e.target.value)}
/>
</InputGroup>
</Form.Group>
<Form.Group className="input-group form-group">
<Form.Label hidden>/</Form.Label>
<InputGroup>
<div className="input-group-prepend">
<InputGroup.Text className="isize">
{isFirm ? (
<FontAwesomeIcon icon={faBook} />
) : (
<FontAwesomeIcon icon={faAddressCard} />
)}
&nbsp; {isFirm ? "IČ" : "RČ"}
</InputGroup.Text>
</div>
<Form.Control
type="text"
placeholder=""
aria-label={isFirm ? "ICO" : "RC"}
name={isFirm ? "ICO" : "RC"}
required
inputMode="numeric"
pattern={isFirm ? "^\\d{8}$" : "^\\d{6}/\\d{3,4}$"}
maxLength={isFirm ? "8" : "11"}
value={isFirm ? ICO : RC}
onChange={
isFirm
? (e) => setICO(e.target.value)
: (e) => setRC(e.target.value)
}
/>
</InputGroup>
</Form.Group>
<Form.Group>
<div className="custom-control custom-checkbox">
<Form.Control
className="custom-control-input"
type="checkbox"
name="GDPR"
id="GDPR"
required
/>
<Form.Label className="custom-control-label" htmlFor="GDPR">
Souhlasím se zpracováním osobních údajů
</Form.Label>
</div>
</Form.Group>
<Form.Group>
<div>
<div className="links">
<a
className="gdpr"
data-toggle="modal"
data-target="#gdprModal"
onClick={handleShow}
>
Informace o zpracování GDPR{" "}
</a>
</div>
<Form.Label hidden>Register</Form.Label>
<Button variant="success" type="submit" disabled={isSubmitting}>
{isSubmitting ? "Odesílání..." : "Registrovat"}
</Button>
</div>
</Form.Group>
</Form>
</Card.Body>
</Card>
<Modal size="lg" id="gdprModal" show={show} onHide={handleClose}>
<Modal.Header closeButton>
<Modal.Title>Informace o zpracování osobních údajů</Modal.Title>
</Modal.Header>
<Modal.Body>
<p>
Při použití Elektronické přepážky a při vyřízení požadavků uživatelů
Elektronické přepážky dochází ke zpracováním osobních údajů
uživatelů správcem{" "}
<strong>
- statutárním městem Ostrava městským obvodem Ostrava-Jih,
</strong>
se sídlem Horní 791/3, 700 30 Ostrava, IČO: 00845451, v rozsahu
jména a příjmení, tel. kontaktu, e-mailové adresy, č. SIPO, č.
nájemní smlouvy, pro níže vymezené účely zpracování.
</p>
<p>
<u> Kontaktní údaje správce</u>: statutární město Ostrava městský
obvod Ostrava-Jih, adresa: Horní 791/3, 700 30 Ostrava
</p>
<p>
e-mail:<a href="mailto:posta@ovajih.cz"> posta@ovajih.cz</a>
</p>
<p>ID datové schránky: 2s3brdz</p>
<p>
<u>Kontaktní údaje pověřence</u>: Martin Krupa, e-mail:
martin.krupa@gdpr-opava.cz, tel. kontakt: +420 724 356 825;
advokátní kancelář KLIMUS &amp; PARTNERS s.r.o., se sídlem Vídeňská
188/119d, 619 00 Brno - Dolní Heršpice, zastoupena Mgr. Romanem
Klimusem, tel. č. +420 602 705 686, e-mail: roman@klimus.cz, ID
datové schránky: ewann52.
</p>
<p>
Účelem zpracování poskytnutých osobních údajů je vyřízení požadavků
uživatelů Elektronické přepážky plnění povinností z uzavřených
nájemních smluv.
</p>
<p>
Osobní údaje mohou být v nezbytně nutném rozsahu poskytovány
následujícím příjemcům externím subjektům zajišťujícím plnění
povinností správce jakožto pronajímatele na základě požadavků
uživatelů Elektronické přepážky.
</p>
<p>
Zpracování výše uvedených osobních údajů bude probíhat po dobu
vyřízení požadavku zaslaného Elektronickou přepážkou a následně
mohou být osobní údaje uživatele uchovávány v nezbytném rozsahu a po
nezbytnou dobu za účelem ochrany práv a právem chráněných zájmů
správce, subjektů údajů nebo jiné dotčené osoby.
</p>
<p>
Zpracování osobních údajů je prováděno na základě právního titulu
plnění smlouvy, splnění právní povinnosti správce.
</p>
<p>
Bližší informace o právech uživatele Elektronické přepážky jako
subjektu údajů, jakož i o možnostech jejich uplatnění, jsou uvedeny
ve Vnitřní směrnici o ochraně osobních údajů.
</p>
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={handleClose}>
Zavřít
</Button>
</Modal.Footer>
</Modal>
</>
);
}
export default RegisterCard;

View File

View File

@@ -0,0 +1,209 @@
import React, { useEffect, useState, useCallback } from 'react';
import { Form, Container, Row, Col, Alert, Spinner, Image, Button as BsButton } from 'react-bootstrap';
import { getAppConfig, createAppConfig, updateAppConfig } from '../api/model/Settings';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPen, faUpload, faRotateRight } from '@fortawesome/free-solid-svg-icons';
/*
Admin Site Settings (AppConfig singleton)
Mirrors UX pattern of UserSettings with inline field editing.
Fields managed:
bank_account, sender_email,
background_image (file), logo (file),
variable_symbol, max_reservations_per_event,
contact_phone, contact_email
Image updates use FormData PATCH (only changed files submitted).
*/
export default function Settings() {
const [config, setConfig] = useState(null);
const [formData, setFormData] = useState({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [editing, setEditing] = useState({});
const [filePreviews, setFilePreviews] = useState({});
// Load singleton
useEffect(() => {
let isMounted = true;
(async () => {
try {
const cur = await getAppConfig();
if (isMounted) {
setConfig(cur);
setFormData(cur || {});
}
} catch (e) {
setError('Nepodařilo se načíst konfiguraci.');
} finally {
if (isMounted) setLoading(false);
}
})();
return () => { isMounted = false; };
}, []);
const startEdit = (name) => setEditing(prev => ({ ...prev, [name]: true }));
const stopEdit = (name) => setEditing(prev => { const c = { ...prev }; delete c[name]; return c; });
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
const handleFile = (name, file) => {
if (!file) return;
setFormData(prev => ({ ...prev, [name]: file }));
setEditing(prev => ({ ...prev, [name]: true }));
const url = URL.createObjectURL(file);
setFilePreviews(prev => ({ ...prev, [name]: url }));
};
const resetFile = (name) => {
setFormData(prev => ({ ...prev, [name]: null }));
setFilePreviews(prev => { const c = { ...prev }; delete c[name]; return c; });
};
const save = useCallback(async (e) => {
e.preventDefault();
setSaving(true); setError(''); setSuccess('');
try {
// Decide if we need multipart (only when NEW files selected)
let result;
const fileFields = ['background_image','logo'];
const hasFiles = fileFields.some(f => formData[f] instanceof File);
if (!config) {
// Creating new
if (hasFiles) {
const fd = new FormData();
['bank_account','sender_email','variable_symbol','max_reservations_per_event','contact_phone','contact_email'].forEach(f => {
if (formData[f] !== undefined && formData[f] !== null && formData[f] !== '') fd.append(f, formData[f]);
});
fileFields.forEach(f => { if (formData[f] instanceof File) fd.append(f, formData[f]); });
result = await createAppConfig(fd);
} else {
result = await createAppConfig(formData);
}
} else {
// Updating existing
if (hasFiles) {
const fd = new FormData();
// Append changed non-file fields
Object.keys(editing).forEach(k => {
if (!fileFields.includes(k)) {
const v = formData[k];
if (v !== undefined && v !== null && v !== '') fd.append(k, v);
}
});
// Append only new file objects
fileFields.forEach(f => { if (formData[f] instanceof File) fd.append(f, formData[f]); });
result = await updateAppConfig(config.id, fd);
} else {
// JSON patch; include edited fields + any file fields cleared (value === null)
const payload = {};
Object.keys(editing).forEach(k => { payload[k] = formData[k]; });
fileFields.forEach(f => { if (formData[f] === null) payload[f] = null; });
result = await updateAppConfig(config.id, payload);
}
}
setConfig(result);
setFormData(result);
setEditing({});
setSuccess('✅ Nastavení uloženo.');
} catch (err) {
setError('Nepodařilo se uložit nastavení.');
} finally {
setSaving(false);
}
}, [config, formData, editing]);
if (loading) return <div className='p-4'><Spinner animation='border' size='sm'/> Načítání...</div>;
const renderField = (label, name, type='text', props={}) => {
const isEditing = !!editing[name];
const value = formData[name] ?? '';
return (
<Form.Group className='mb-3' controlId={`cfg-${name}`}>
<Form.Label>{label}</Form.Label>
{isEditing ? (
<Form.Control
type={type}
name={name}
value={value}
onChange={handleChange}
onBlur={() => stopEdit(name)}
{...props}
autoFocus
/>
) : (
<div className='d-flex justify-content-between align-items-center border rounded px-2 py-1 bg-light' style={{minHeight:38}}>
<span>{value || <i>(neuvedeno)</i>}</span>
<BsButton variant='link' size='sm' onClick={() => startEdit(name)} aria-label={`edit ${label}`} style={{textDecoration:'none'}}>
<FontAwesomeIcon icon={faPen} />
</BsButton>
</div>
)}
</Form.Group>
);
};
const renderImageField = (label, name) => {
const currentUrl = filePreviews[name] || (config && config[name]);
return (
<Form.Group className='mb-3'>
<Form.Label>{label}</Form.Label>
<div className='d-flex gap-3 align-items-start flex-wrap'>
<div style={{width:150}} className='text-center'>
{currentUrl ? (
<Image src={currentUrl} alt={label} thumbnail style={{maxHeight:120, objectFit:'contain'}} />
) : <div className='border rounded d-flex align-items-center justify-content-center bg-light' style={{height:120}}>Žádný</div>}
</div>
<div className='d-flex flex-column gap-2'>
<Form.Control type='file' accept='image/*' onChange={(e)=>handleFile(name, e.target.files[0])} />
{(filePreviews[name] || formData[name] === null) && (
<BsButton variant='outline-secondary' size='sm' onClick={()=>resetFile(name)}><FontAwesomeIcon icon={faRotateRight}/> Reset</BsButton>
)}
</div>
</div>
</Form.Group>
);
};
return (
<Container className='mt-4'>
<h1 className='mb-4'>Nastavení webu</h1>
{error && <Alert variant='danger'>{error}</Alert>}
{success && <Alert variant='success'>{success}</Alert>}
<Form onSubmit={save} encType="multipart/form-data">
<h4 className='text-secondary mt-3'>Obecné</h4>
<Row>
<Col md={6}>{renderField('Číslo účtu','bank_account')}</Col>
<Col md={6}>{renderField('Odesílací e-mail','sender_email','email')}</Col>
</Row>
<Row>
<Col md={4}>{renderField('Variabilní symbol','variable_symbol','number')}</Col>
<Col md={4}>{renderField('Max. rezervací na akci','max_reservations_per_event','number',{min:1})}</Col>
<Col md={4}>{renderField('Kontaktní telefon','contact_phone','tel')}</Col>
</Row>
<Row>
<Col md={6}>{renderField('Kontaktní e-mail','contact_email','email')}</Col>
</Row>
<hr />
<h4 className='text-secondary mt-3'>Vizuální</h4>
<Row>
<Col md={6}>{renderImageField('Logo','logo')}</Col>
<Col md={6}>{renderImageField('Pozadí','background_image')}</Col>
</Row>
<div className='d-flex justify-content-end gap-3 mt-4'>
<BsButton type='submit' variant='primary' disabled={saving}>
{saving ? '💾 Ukládání...' : 'Uložit změny'}
</BsButton>
</div>
</Form>
</Container>
);
}

View File

@@ -0,0 +1,72 @@
import {
Container,
Nav,
Navbar,
NavDropdown,
Form,
Button,
} from "react-bootstrap";
import {
IconHome,
IconCalendarEvent,
IconClipboardList,
IconMapPin,
IconUsers,
IconReceipt2 ,
IconPackage,
IconBox,
IconTrash,
IconWorldCog,
} from "@tabler/icons-react";
function Sidebar() {
return (
<div className="bg-light h-100 d-flex flex-column pt-3 px-2" style={{ minHeight: 0}}>
<Nav defaultActiveKey="/home" className="flex-column flex-grow-1">
<Nav.Link href="/home">
<IconHome size={20} style={{ marginRight: 8, marginBottom: 2 }} />
Home
</Nav.Link>
<hr />
<Nav.Link href="/manage/squares" eventKey="link-3">
<IconMapPin size={20} style={{ marginRight: 8, marginBottom: 2 }} />
Náměstí
</Nav.Link>
<Nav.Link href="/manage/events" eventKey="link-1">
<IconCalendarEvent size={20} style={{ marginRight: 8, marginBottom: 2 }} />
Akce
</Nav.Link>
<hr />
<Nav.Link href="/manage/reservations" eventKey="link-2">
<IconClipboardList size={20} style={{ marginRight: 8, marginBottom: 2 }} />
Rezervace
</Nav.Link>
<Nav.Link href="/manage/orders" eventKey="link-5">
<IconReceipt2 size={20} style={{ marginRight: 8, marginBottom: 2 }} />
Objednávky
</Nav.Link>
<Nav.Link href="/manage/products" eventKey="link-6">
<IconPackage size={20} style={{ marginRight: 8, marginBottom: 2 }} />
Produkty
</Nav.Link>
<hr />
<Nav.Link href="/manage/users" eventKey="link-4">
<IconUsers size={20} style={{ marginRight: 8, marginBottom: 2 }} />
Uživatelé
</Nav.Link>
<hr />
<Nav.Link href="/manage/bin" eventKey="link-7">
<IconTrash size={20} style={{ marginRight: 8, marginBottom: 2 }} />
Koš
</Nav.Link>
<Nav.Link href="/manage/settings" eventKey="link-8">
<IconWorldCog size={20} style={{ marginRight: 8, marginBottom: 2 }} />
Nastavení
</Nav.Link>
</Nav>
</div>
);
}
export default Sidebar;

View File

@@ -0,0 +1,197 @@
import sortBy from "lodash/sortBy";
import {
useReactTable,
getCoreRowModel,
getSortedRowModel,
flexRender,
} from '@tanstack/react-table';
import { useEffect, useMemo, useState } from "react";
function Table({
data = [],
columns = [],
fetching = false,
defaultSort = "id",
modalTitle = "Details",
renderModalContent,
onQueryChange,
initialQuery = "",
withGlobalSearch = true,
withActionsColumn = true,
withTableBorder,
borderRadius,
highlightOnHover,
verticalAlign,
titlePadding = "6px 8px", // default smaller padding
...props
}) {
const [sortStatus, setSortStatus] = useState({
columnAccessor: defaultSort,
direction: "asc",
});
const [records, setRecords] = useState([]);
const [query, setQuery] = useState(initialQuery);
const [debouncedQuery, setDebouncedQuery] = useState(query);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedQuery(query);
}, 200);
return () => clearTimeout(handler);
}, [query]);
const [filters, setFilters] = useState({});
// Remove filter edit state, always show filter fields
// Expose query changes to parent
useEffect(() => {
if (onQueryChange) {
onQueryChange(query);
}
}, [query, onQueryChange]);
// Apply sorting and filtering
useEffect(() => {
if (!data || data.length === 0) {
setRecords([]);
return;
}
console.log(data)
let filteredData = [...data];
// Apply column filters (substring search, case-insensitive)
Object.entries(filters).forEach(([key, value]) => {
if (value && value.length > 0) {
filteredData = filteredData.filter(item => {
const cellValue = item[key];
return cellValue !== undefined && String(cellValue).toLowerCase().includes(value.toLowerCase());
});
}
});
// Apply sorting
const sorted = sortBy(filteredData, sortStatus.columnAccessor);
const sortedRecords = sortStatus.direction === "desc"
? sorted.reverse()
: sorted;
setRecords(sortedRecords);
}, [data, sortStatus, debouncedQuery, filters]);
// Enhanced columns with actions
const enhancedColumns = [...columns];
// Prepare columns for TanStack Table
const tableColumns = useMemo(() =>
enhancedColumns.map(col => ({
accessorKey: col.accessor,
header: col.title || col.accessor,
cell: col.render ? info => col.render(info.row.original) : info => info.getValue(),
enableSorting: col.accessor !== 'actions',
})),
[enhancedColumns]
);
// TanStack Table instance
const table = useReactTable({
data: records,
columns: tableColumns,
state: {
sorting: sortStatus.columnAccessor ? [{
id: sortStatus.columnAccessor,
desc: sortStatus.direction === 'desc',
}] : [],
},
onSortingChange: updater => {
const sort = Array.isArray(updater) ? updater[0] : updater?.[0];
if (sort) {
setSortStatus({
columnAccessor: sort.id,
direction: sort.desc ? 'desc' : 'asc',
});
}
},
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
manualSorting: true,
});
return (
<div className="custom-table-wrapper">
<table
className={`custom-table${withTableBorder ? " table-bordered" : ""}`}
style={{
borderRadius,
width: "100%",
borderCollapse: "separate",
borderSpacing: 0,
fontSize: "0.9rem",
tableLayout: "fixed",
}}
>
<colgroup>
{columns.map(col => (
<col
key={col.accessor}
style={{ width: col.width || "auto" }}
/>
))}
</colgroup>
<thead>
<tr>
{columns.map((col, idx) => (
<th
key={col.accessor || idx}
style={{
padding: titlePadding,
textAlign: col.textAlign || "left",
minWidth: col.minWidth || undefined,
maxWidth: col.maxWidth || undefined,
// ...other style...
}}
>
{col.title}
{col.filter && <div style={{ marginTop: 2 }}>{col.filter}</div>}
</th>
))}
</tr>
</thead>
<tbody>
{table.getRowModel().rows.length === 0 ? (
<tr>
<td colSpan={table.getAllLeafColumns().length} style={{
textAlign: 'center',
padding: 24,
color: '#888',
}}>
No data
</td>
</tr>
) : (
table.getRowModel().rows.map(row => (
<tr key={row.id} style={{ borderBottom: '1px solid var(--mantine-color-gray-2)' }}>
{row.getVisibleCells().map(cell => (
<td key={cell.id} style={{
padding: '10px 8px',
fontSize: 15,
width: cell.column.getSize(),
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
);
}
export default Table;

View File

@@ -0,0 +1,178 @@
import React, { useEffect, useState } from "react";
import { Form, Container, Alert, Col, Row } from "react-bootstrap";
import Button from "react-bootstrap/Button";
import { apiRequest } from "../api/auth";
import { useNavigate } from "react-router-dom";
// Import FontAwesome
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faPen } from "@fortawesome/free-solid-svg-icons";
export default function UserSettings() {
const [user, setUser] = useState(null);
const [formData, setFormData] = useState({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [editingFields, setEditingFields] = useState({});
const navigate = useNavigate();
useEffect(() => {
const loadUser = async () => {
try {
const data = await apiRequest("get", "/account/user/me/");
setUser(data);
setFormData(data);
} catch {
setError("Nepodařilo se načíst profil.");
} finally {
setLoading(false);
}
};
loadUser();
}, []);
const handleChange = (e) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
};
const startEdit = (field) => {
setEditingFields((prev) => ({ ...prev, [field]: true }));
};
const stopEdit = (field) => {
setEditingFields((prev) => {
const copy = { ...prev };
delete copy[field];
return copy;
});
};
const handleSave = async (e) => {
e.preventDefault();
if (!user?.id) return;
setSaving(true);
setError("");
try {
const updated = await apiRequest("patch", `/account/users/${user.id}/`, {
first_name: formData.first_name,
last_name: formData.last_name,
RC: formData.RC,
ICO: formData.ICO,
street: formData.street,
city: formData.city,
PSC: formData.PSC,
bank_account: formData.bank_account,
phone_number: formData.phone_number,
email: formData.email,
});
setUser(updated);
setFormData(updated);
setEditingFields({});
alert("✅ Údaje byly uloženy.");
} catch {
setError("❌ Nepodařilo se uložit změny.");
} finally {
setSaving(false);
}
};
const handleResetPassword = () => {
navigate("/reset-password");
};
if (loading) return <p> Načítání...</p>;
const renderField = (label, name, type = "text") => {
const isEditing = !!editingFields[name];
const value = formData[name] ?? "";
return (
<Form.Group className="mb-3" controlId={`field-${name}`}>
<Form.Label>{label}</Form.Label>
{isEditing ? (
<Form.Control
type={type}
name={name}
value={value}
onChange={handleChange}
onBlur={() => stopEdit(name)}
autoFocus
/>
) : (
<div
style={{
padding: "8px 12px",
border: "1px solid #ced4da",
borderRadius: 4,
minHeight: "38px",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
backgroundColor: "#f8f9fa",
}}
>
<span>{value || <i>(neuvedeno)</i>}</span>
<Button
variant="link"
size="sm"
onClick={() => startEdit(name)}
style={{ textDecoration: "none" }}
aria-label={`Upravit ${label}`}
>
<FontAwesomeIcon icon={faPen} />
</Button>
</div>
)}
</Form.Group>
);
};
return (
<Container className="mt-5">
<h1 className="mb-4">Nastavení uživatele</h1>
{error && <Alert variant="danger">{error}</Alert>}
<Form onSubmit={handleSave}>
<Row>
<Col>{renderField("Jméno", "first_name")}</Col>
<Col>{renderField("Příjmení", "last_name")}</Col>
</Row>
<hr />
<h3 className="mb-4 text-secondary">Sídlo</h3>
<Row>
<Col>{renderField("Ulice a č.p.", "street")}</Col>
<Col>{renderField("Město", "city")}</Col>
</Row>
<Row>
<Col>{renderField("PSČ", "PSC")}</Col>
<Col></Col>
</Row>
<hr />
<Row>
<Col>{renderField("Číslo účtu", "bank_account")}</Col>
<Col>{renderField("Telefon", "phone_number", "tel")}</Col>
</Row>
{renderField("Email", "email", "email")}
<div className="d-flex justify-content-between align-items-center mt-4">
<Button type="submit" variant="primary" disabled={saving}>
{saving ? "💾 Ukládání..." : "Uložit změny"}
</Button>
<Button variant="warning" onClick={handleResetPassword}>
Resetovat heslo
</Button>
</div>
</Form>
</Container>
);
}

View File

@@ -0,0 +1,135 @@
import { useEffect, useState } from "react";
import { Form, Button, Alert, Spinner } from "react-bootstrap";
import { fetchEnumFromSchemaJson } from "../../api/get_chocies";
import { apiRequest } from "../../api/auth";
import { useContext } from "react";
import { UserContext } from "../../context/UserContext";
function TicketForm() {
const { user } = useContext(UserContext) || {};
const [categoryOptions, setCategoryOptions] = useState([]);
const [formData, setFormData] = useState({
title: "",
description: "",
category: "",
});
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState(null);
const [success, setSuccess] = useState(false);
useEffect(() => {
const loadEnums = async () => {
try {
const [categories, urgencies] = await Promise.all([
fetchEnumFromSchemaJson("/api/service-tickets/", "get", "category"),
]);
setCategoryOptions(categories);
} catch (err) {
console.error("Chyba při načítání enum hodnot:", err);
setError("Nepodařilo se načíst možnosti formuláře.");
} finally {
setLoading(false);
}
};
loadEnums();
}, []);
const handleChange = (e) => {
setFormData((prev) => ({ ...prev, [e.target.name]: e.target.value }));
};
const handleSubmit = async (e) => {
e.preventDefault();
setSubmitting(true);
setError(null);
setSuccess(false);
try {
const payload = {
...formData,
user: user?.id,
};
await apiRequest("post", "/service-tickets/", payload);
setSuccess(true);
setFormData({
title: "",
description: "",
category: "",
});
} catch (err) {
console.error(err);
setError("Chyba při odesílání formuláře.");
} finally {
setSubmitting(false);
}
};
if (loading)
return (
<Spinner animation="border" role="status">
<span className="visually-hidden">Načítání</span>
</Spinner>
);
return (
<Form onSubmit={handleSubmit} className="p-4 border rounded shadow-sm bg-light m-5">
<h3>Odeslat Ticket</h3>
{error && <Alert variant="danger">{error}</Alert>}
{success && <Alert variant="success">Ticket byl úspěšně odeslán.</Alert>}
<Form.Group className="mb-3">
<Form.Label>Název</Form.Label>
<Form.Control
type="text"
name="title"
value={formData.title}
onChange={handleChange}
required
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Popis</Form.Label>
<Form.Control
as="textarea"
name="description"
value={formData.description}
onChange={handleChange}
rows={4}
required
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Kategorie</Form.Label>
<Form.Select
name="category"
value={formData.category}
onChange={handleChange}
required
>
<option value="">Vyberte kategorii</option>
{categoryOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</Form.Select>
</Form.Group>
<Button type="submit" disabled={submitting}>
{submitting ? "Odesílám..." : "Odeslat Ticket"}
</Button>
</Form>
);
}
export default TicketForm;

View File

@@ -0,0 +1,345 @@
import { useState, useEffect } from 'react';
import { ProgressBar, Form, InputGroup, Table, Spinner, Alert, Card, Container, Row, Col } from 'react-bootstrap';
import dayjs from 'dayjs';
import { useNavigate } from 'react-router-dom';
import Step1SelectSquare from './Step1SelectSquare';
import Step2SelectEvent from './Step2SelectEvent';
import Step3Map from './Step3Map';
import Step4Summary from './Step4Summary';
import orderAPI from '../../api/model/order';
import reservationAPI from '../../api/model/reservation';
import userAPI from '../../api/model/user';
import { fetchEnumFromSchemaJson } from '../../api/get_chocies';
// TODO: Replace this with real user role detection (e.g., from context or props)
const isAdminOrClerk = true; // Set to true for demonstration
// List of available filters (should match backend filters.py)
const USER_FILTERS_BASE = [
{ key: "role", label: "Role", type: "select" },
{ key: "account_type", label: "Typ účtu", type: "select" },
{ key: "email", label: "Email", type: "text" },
{ key: "phone_number", label: "Telefon", type: "text" },
{ key: "city", label: "Město", type: "text" },
{ key: "street", label: "Ulice", type: "text" },
{ key: "PSC", label: "PSČ", type: "text" },
{ key: "ICO", label: "IČO", type: "text" },
{ key: "RC", label: "Rodné číslo", type: "text" },
{ key: "var_symbol", label: "Variabilní symbol", type: "number" },
{ key: "bank_account", label: "Bankovní účet", type: "text" },
{ key: "is_active", label: "Aktivní", type: "checkbox" },
{ key: "email_verified", label: "Email ověřen", type: "checkbox" },
{ key: "create_time_after", label: "Vytvořeno po", type: "date" },
{ key: "create_time_before", label: "Vytvořeno před", type: "date" },
];
const ReservationWizard = () => {
const [data, setData] = useState({
square: null,
event: null,
slots: [],
user: '', // New field for user ID
date: null, // Ensure date is present for reservation
note: '', // Ensure note is present
});
const [step, setStep] = useState(1);
const [userSearch, setUserSearch] = useState('');
const [userResults, setUserResults] = useState([]);
const [isSearching, setIsSearching] = useState(false);
const [duration, setDuration] = useState(1); // 1, 7, or 30
const [userFilters, setUserFilters] = useState({});
const [roleChoices, setRoleChoices] = useState([]);
const [accountTypeChoices, setAccountTypeChoices] = useState([]);
const navigate = useNavigate();
// Fetch choices for select fields on mount (inspired by create-user.jsx)
useEffect(() => {
fetchEnumFromSchemaJson("/api/account/users/", "get", "role")
.then((choices) => setRoleChoices(choices))
.catch(() => setRoleChoices([
{ value: "admin", label: "Administrátor" },
{ value: "seller", label: "Prodejce" },
{ value: "squareManager", label: "Správce tržiště" },
{ value: "cityClerk", label: "Úředník" },
{ value: "checker", label: "Kontrolor" },
]));
fetchEnumFromSchemaJson("/api/account/users/", "get", "account_type")
.then((choices) => setAccountTypeChoices(choices))
.catch(() => setAccountTypeChoices([
{ value: "company", label: "Firma" },
{ value: "individual", label: "Fyzická osoba" },
]));
}, []);
// Update filter value
const handleFilterChange = (key, value, type) => {
setUserFilters(f => ({
...f,
[key]: type === "checkbox" ? value.target.checked : value.target.value
}));
};
// Search users with all filters
const handleUserSearch = async () => {
setIsSearching(true);
// Remove empty values
const params = Object.fromEntries(
Object.entries(userFilters).filter(([_, v]) => v !== "" && v !== undefined && v !== null)
);
try {
let results = await userAPI.searchUsers(params);
if (results && typeof results === 'object' && !Array.isArray(results) && Array.isArray(results.results)) {
results = results.results;
}
setUserResults(Array.isArray(results) ? results : []);
} catch (e) {
setUserResults([]);
}
setIsSearching(false);
};
const next = () => setStep((s) => Math.min(s + 1, 4));
const prev = () => setStep((s) => Math.max(s - 1, 1));
const handleSubmit = async () => {
try {
const slot = data.slots[0];
// Ensure slot and date are present
if (!slot || !data.date || !data.date.start || !data.date.end) {
alert('Vyberte termín a slot.');
return;
}
// Ensure event is present and valid
if (!data.event || typeof data.event !== 'object' || !data.event.id) {
alert('Chybí událost (event). Vyberte prosím událost.');
return;
}
// Use selected date range from Step3Map (date only)
let reserved_from = data.date.start;
let reserved_to = data.date.end;
// Clamp reserved_from and reserved_to to event boundaries
const eventStart = dayjs(data.event.start, "YYYY-MM-DD");
const eventEnd = dayjs(data.event.end, "YYYY-MM-DD");
if (dayjs(reserved_from).isBefore(eventStart)) reserved_from = eventStart.format("YYYY-MM-DD");
if (dayjs(reserved_to).isAfter(eventEnd)) reserved_to = eventEnd.format("YYYY-MM-DD");
const reservationData = {
event: data.event.id,
market_slot: slot.id,
reserved_from,
reserved_to,
used_extension: slot.used_extension || 0,
note: data.note || null,
};
if (isAdminOrClerk && data.user) {
reservationData.user = data.user;
}
console.log('Odesílaná rezervace:', reservationData);
// Create reservation and get its ID
const ResponseReservation = await reservationAPI.createReservation(reservationData);
console.log('Response:', ResponseReservation);
const response = await orderAPI.createOrder({
user_id: data.user || null,
note: data.note || null,
reservation_id: ResponseReservation.id, // Use the reservation ID
});
alert('Objednávka byla úspěšně odeslána!');
console.log('📦 Objednáno:', response);
// Redirect to payment page after alert confirmation
navigate(`/payment/${response.id}`);
} catch (error) {
// Log the error and show backend validation errors if present
console.error('❌ Chyba při odesílání objednávky:', error);
if (error.response) {
console.error('Backend response:', error.response);
// Log backend error details for debugging
if (error.response.data) {
console.error('Backend error details:', error.response.data);
}
}
if (error.response && error.response.data) {
alert(
'Chyba při odesílání objednávky:\n' +
JSON.stringify(error.response.data, null, 2)
);
} else {
alert('Něco se pokazilo při odesílání objednávky.');
}
}
};
return (
<>
<ProgressBar now={(step / 4) * 100} className="mb-4" />
{/* Admin/Clerk user filter bar */}
{isAdminOrClerk && (
<Card className="mb-4" style={{ border: '1px solid #dee2e6' }}>
<Card.Body>
<Alert variant="danger" className="mb-2">
<b>Výběr uživatele pro objednávku</b><br />
Vyplňte libovolné pole pro filtrování uživatelů. Pokud pole zůstanou prázdná, objednávka bude vytvořena na vaš momentálně přihlášený účet !!!
</Alert>
<Form>
<div className="row">
{/* Render non-checkbox fields */}
{USER_FILTERS_BASE.filter(f => f.type !== "checkbox").map(f => (
<div className="col-md-4 mb-2" key={f.key}>
<Form.Group controlId={`user-filter-${f.key}`}>
<Form.Label>{f.label}</Form.Label>
{f.type === "select" && f.key === "role" ? (
<Form.Select
value={userFilters[f.key] || ""}
onChange={e => handleFilterChange(f.key, e, f.type)}
>
<option value="">-- Vyberte --</option>
{roleChoices.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</Form.Select>
) : f.type === "select" && f.key === "account_type" ? (
<Form.Select
value={userFilters[f.key] || ""}
onChange={e => handleFilterChange(f.key, e, f.type)}
>
<option value="">-- Vyberte --</option>
{accountTypeChoices.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</Form.Select>
) : f.type === "select" ? (
<Form.Select
value={userFilters[f.key] || ""}
onChange={e => handleFilterChange(f.key, e, f.type)}
>
<option value="">-- Vyberte --</option>
</Form.Select>
) : (
<Form.Control
type={f.type}
value={userFilters[f.key] || ""}
onChange={e => handleFilterChange(f.key, e, f.type)}
autoComplete="off"
/>
)}
</Form.Group>
</div>
))}
</div>
{/* Render each checkbox and label in a separate row */}
<Container className="mb-3">
{USER_FILTERS_BASE.filter(f => f.type === "checkbox").map(f => (
<Row key={f.key} className="align-items-center mb-2">
<Col xs="auto">
<Form.Check
type="checkbox"
checked={!!userFilters[f.key]}
onChange={e => handleFilterChange(f.key, e, f.type)}
id={`user-filter-${f.key}`}
/>
</Col>
<Col>
<Form.Label htmlFor={`user-filter-${f.key}`} className="mb-0">{f.label}</Form.Label>
</Col>
</Row>
))}
</Container>
<button
className="btn btn-primary mt-2"
type="button"
onClick={handleUserSearch}
disabled={isSearching}
>
Vyhledat uživatele
{isSearching && (
<Spinner animation="border" size="sm" className="ms-2" />
)}
</button>
</Form>
{userResults.length > 0 && (
<Table striped bordered hover size="sm" className="mt-2" style={{ maxWidth: 400 }}>
<thead>
<tr>
<th>Uživatelské jméno</th>
<th>ID</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{userResults.map(user => (
<tr key={user.id}>
<td>{user.username}</td>
<td>{user.id}</td>
<td>
<button
className="btn btn-primary btn-sm"
onClick={() => {
setData(d => ({ ...d, user: user.id }));
setUserFilters({ username: user.username });
setUserResults([]);
}}
>
Vybrat
</button>
</td>
</tr>
))}
</tbody>
</Table>
)}
{data.user && (
<Alert variant="success" className="mt-2">
Vybraný uživatel ID: <b>{data.user}</b>
</Alert>
)}
</Card.Body>
</Card>
)}
{step === 1 && (
<Step1SelectSquare data={data} setData={setData} next={next} />
)}
{step === 2 && (
<Step2SelectEvent data={data} setData={setData} next={next} prev={prev} />
)}
{step === 3 && (
<>
{/* Pass duration and setDuration to Step3Map */}
<Step3Map
data={data}
setData={setData}
next={next}
prev={prev}
duration={duration}
setDuration={setDuration}
/>
</>
)}
{step === 4 && (
<Step4Summary
formData={{
selectedSquare: data.square,
selectedEvent: data.event,
selectedSlot: data.slots,
selectedUser: data.user || null,
note: data.note || '',
}}
onBack={prev}
onSubmit={handleSubmit}
note={data.note || ''}
setNote={note => setData(d => ({ ...d, note }))}
/>
)}
</>
);
};
export default ReservationWizard;

View File

@@ -0,0 +1,88 @@
import React, { useEffect, useState } from 'react';
import { Card, Button, Row, Col, Spinner } from 'react-bootstrap';
import squareAPI from '../../api/model/square';
const Step1SelectSquare = ({ data, setData, next }) => {
const [squares, setSquares] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
squareAPI.getSquares()
.then(result => {
setSquares(result);
setLoading(false);
})
.catch(() => {
setSquares([]);
setLoading(false);
});
}, []);
const selectSquare = (square) => {
setData(prev => ({
...prev,
square,
event: null,
slots: [],
}));
};
if (loading) return <Spinner animation="border" role="status"><span className="visually-hidden">Načítám...</span></Spinner>;
return (
<>
<h2>Vyber náměstí</h2>
<Row xs={1} sm={2} md={3} lg={4} className="g-4">
{squares.map(sq => {
const selected = data.square?.id === sq.id;
return (
<Col key={sq.id}>
<Card
onClick={() => selectSquare(sq)}
border={selected ? 'primary' : undefined}
className={`h-100 cursor-pointer ${selected ? 'shadow-lg' : ''}`}
style={{ userSelect: 'none' }}
>
{sq.image
? <Card.Img variant="top" src={sq.image} style={{ height: 150, objectFit: 'cover' }} alt={sq.name} />
: <div style={{
height: 150,
backgroundColor: '#eee',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#999',
fontStyle: 'italic',
fontSize: 14,
}}>
Obrázek chybí
</div>
}
<Card.Body>
<Card.Title>{sq.name}</Card.Title>
<Card.Subtitle className="mb-2 text-muted">
{sq.street}, {sq.city} ({sq.psc})
</Card.Subtitle>
<Card.Text style={{ whiteSpace: 'pre-line', height: 60, overflow: 'hidden' }}>
{sq.description}
</Card.Text>
</Card.Body>
</Card>
</Col>
);
})}
</Row>
<div className="mt-3 d-flex justify-content-end">
<Button
onClick={next}
disabled={!data.square}
variant="primary"
>
Pokračovat
</Button>
</div>
</>
);
};
export default Step1SelectSquare;

View File

@@ -0,0 +1,120 @@
import React, { useEffect, useState } from 'react';
import { Card, Button, Row, Col, Spinner, Alert } from 'react-bootstrap';
import eventAPI from '../../api/model/event';
import dayjs from 'dayjs';
const Step2SelectEvent = ({ data, setData, next, prev }) => {
const [events, setEvents] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
if (!data.square?.id) {
setEvents([]);
return;
}
setLoading(true);
setError(null);
eventAPI.getEvents({ square: data.square.id })
.then(result => {
setEvents(result);
setLoading(false);
})
.catch(() => {
setError("Nepodařilo se načíst události");
setLoading(false);
});
}, [data.square]);
const selectEvent = (event) => {
setData(prev => ({
...prev,
event,
slots: [],
}));
};
if (!data.square) {
return (
<>
<Alert variant="warning">
Nejprve vyberte náměstí v předchozím kroku.
</Alert>
<Button onClick={prev}>Zpět</Button>
</>
);
}
if (loading) return <Spinner animation="border" role="status"><span className="visually-hidden">Načítám...</span></Spinner>;
if (error) return <Alert variant="danger">{error}</Alert>;
if (events.length === 0) return <p>Pro vybrané náměstí nebyly nalezeny žádné události.</p>;
// Filter events to only show current or future events
const now = dayjs().startOf('day');
const filteredEvents = events.filter(event => {
// event.start and event.end are now date strings (YYYY-MM-DD)
const end = dayjs(event.end, "YYYY-MM-DD");
return end.isAfter(now) || end.isSame(now, 'day');
});
return (
<>
<h2>Vyber událost</h2>
<Row xs={1} sm={2} md={3} lg={3} className="g-4">
{filteredEvents.map(ev => {
const selected = data.event?.id === ev.id;
return (
<Col key={ev.id} className='mb-5'>
<Card
onClick={() => selectEvent(ev)}
border={selected ? 'primary' : undefined}
className={`h-100 cursor-pointer ${selected ? 'shadow-lg' : ''}`}
style={{ userSelect: 'none' }}
>
{ev.image
? <Card.Img variant="top" src={ev.image} alt={ev.name} style={{ height: 150, objectFit: 'cover' }} />
: <div style={{
height: 150,
backgroundColor: '#eee',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#999',
fontStyle: 'italic',
fontSize: 14,
}}>
Obrázek chybí
</div>
}
<Card.Body>
<Card.Title>{ev.name}</Card.Title>
<Card.Subtitle className="mb-2 text-muted">
{ev.start} {ev.end}
</Card.Subtitle>
<Card.Text style={{ whiteSpace: 'pre-line', height: 70, overflow: 'hidden' }}>
{ev.description}
</Card.Text>
<Card.Text><strong>Cena za :</strong> {ev.price_per_m2} </Card.Text>
</Card.Body>
</Card>
</Col>
);
})}
</Row>
<div className="mt-3 d-flex justify-content-between">
<Button onClick={prev} variant="secondary">Zpět</Button>
<Button
onClick={next}
disabled={!data.event}
variant="primary"
>
Pokračovat
</Button>
</div>
</>
);
};
export default Step2SelectEvent;

View File

@@ -0,0 +1,223 @@
import { useEffect, useState, useCallback } from "react";
import { Button, Alert, Spinner, Col, Row, Container, Modal } from "react-bootstrap";
import DynamicGrid from "../DynamicGrid";
import eventAPI from "../../api/model/event";
import orderAPI from "../../api/model/order";
import reservationAPI from "../../api/model/reservation";
import { format } from "date-fns";
import DaySelectorCalendar from "./step3/Calendar";
import dayjs from "dayjs";
export default function Step3Map({ data, setData, next, prev }) {
const [slots, setSlots] = useState([]);
const [selectedSlotIdx, setSelectedSlotIdx] = useState(null);
const [showDateModal, setShowDateModal] = useState(false);
const [modalSlot, setModalSlot] = useState(null);
const [selectedRange, setSelectedRange] = useState(null);
const [price, setPrice] = useState(null);
const [loadingPrice, setLoadingPrice] = useState(false);
const [priceError, setPriceError] = useState(null);
const [validationError, setValidationError] = useState('');
const [bookedRanges, setBookedRanges] = useState([]);
// Load all slots for the selected event on initial load
useEffect(() => {
if (!data?.event?.id) return;
eventAPI.getEventById(data.event.id).then((eventData) => {
if (eventData?.market_slots) {
const mappedSlots = eventData.market_slots.map((slot) => ({
...slot,
x: slot.x,
y: slot.y,
w: slot.width,
h: slot.height,
status: slot.status,
}));
setSlots(mappedSlots);
}
});
}, [data?.event?.id]);
// When user clicks a slot, open date picker modal (with delay)
const handleSlotSelect = async (idx) => {
setSelectedSlotIdx(idx);
setModalSlot(slots[idx]);
setValidationError('');
setPrice(null);
setPriceError(null);
setSelectedRange(null);
// Fetch reserved days for this slot (expects array of dates)
const slotId = slots[idx]?.id;
if (slotId) {
try {
const res = await reservationAPI.getReservedRanges(slotId);
// Expecting { reserved_days: [date1, date2, ...] }
setBookedRanges(res?.reserved_days ?? []);
} catch (e) {
setBookedRanges([]);
}
} else {
setBookedRanges([]);
}
setTimeout(() => {
setShowDateModal(true);
console.log("data:", data);
}, 500); // small delay for state propagation
};
// When user picks a date range, submit to backend
const handleDateRangeSubmit = async (rangeObj) => {
if (!modalSlot?.id || !rangeObj?.start || !rangeObj?.end) return;
setLoadingPrice(true);
setPriceError(null);
setValidationError('');
try {
// Use date only (YYYY-MM-DD)
const reserved_from = dayjs(rangeObj.start).format("YYYY-MM-DD");
const reserved_to = dayjs(rangeObj.end).format("YYYY-MM-DD");
// Call backend to check reservation and get price
const res = await orderAPI.calculatePrice({
slot: modalSlot.id,
reserved_from,
reserved_to,
used_extension: 0,
});
// If backend returns error (e.g., slot reserved), show validation error
if (res?.error) {
setValidationError(res.error || "Toto místo je již rezervováno pro tento termín.");
setPrice(null);
} else {
setPrice(res.final_price ?? null);
setSelectedRange(rangeObj);
setData((prevData) => ({
...prevData,
slots: [{ ...modalSlot }],
date: {
start: reserved_from,
end: reserved_to,
},
}));
setValidationError('');
}
} catch (error) {
setPriceError("Nepodařilo se spočítat cenu rezervace.");
setPrice(null);
} finally {
setLoadingPrice(false);
setShowDateModal(false);
console.log("Data:", data);
}
};
// Validate before next
const validateSelection = () => {
if (!data.slots || data.slots.length === 0 || !selectedRange) {
setValidationError('Musíte vybrat místo a termín.');
return false;
}
setValidationError('');
return true;
};
const handleNext = () => {
if (validateSelection()) {
next();
}
};
// Get grid config from selected square or fallback to defaults
const gridConfig = data.square
? {
cols: data.square.grid_cols || 60,
rows: data.square.grid_rows || 44,
cellSize: data.square.cellsize || 20,
}
: { cols: 60, rows: 44, cellSize: 20 };
return (
<div className="d-flex flex-column align-items-center gap-3 w-100">
<Container className="w-100 mb-5">
<Row>
<Col>
<h5>Vyberte místo na mapě:</h5>
<p>Klikněte na volné místo pro výběr termínu rezervace.</p>
</Col>
<Col>
{loadingPrice ? (
<Spinner animation="border" />
) : priceError ? (
<Alert variant="danger">{priceError}</Alert>
) : price !== null ? (
<Alert variant="info">
Cena za rezervaci: <strong>{price} </strong>
</Alert>
) : null}
</Col>
</Row>
</Container>
{/* Always show map with all slots */}
<DynamicGrid
config={gridConfig}
reservations={slots}
onReservationsChange={() => {}}
selectedIndex={selectedSlotIdx}
onSelectedIndexChange={handleSlotSelect}
static={true}
multiSelect={false}
clickableStatic={true}
backgroundImage={data.square?.image} // <-- use image from API if present
ref={el => {
if (el) {
console.log('[Step3Map] DynamicGrid props:', {
selectedIndex: selectedSlotIdx,
slots,
});
}
}}
/>
{/* Date picker modal for slot selection */}
<Modal
show={showDateModal}
onHide={() => setShowDateModal(false)}
centered
>
<Modal.Header closeButton>
<Modal.Title>Vyberte termín pro místo</Modal.Title>
</Modal.Header>
<Modal.Body>
<DaySelectorCalendar
onSelectDate={handleDateRangeSubmit}
eventStart={data?.event?.start ? new Date(data.event.start) : null}
eventEnd={data?.event?.end ? new Date(data.event.end) : null}
defaultDate={data?.event?.start ? new Date(data.event.start) : null}
bookedRanges={bookedRanges} // <-- now array of dates
/>
</Modal.Body>
</Modal>
{validationError && (
<div style={{ color: 'red', marginBottom: 8 }}>{validationError}</div>
)}
<div className="d-flex justify-content-between w-100 mt-4 px-4">
<Button variant="secondary" onClick={prev}>
Zpět
</Button>
<Button
variant="primary"
onClick={handleNext}
disabled={!data.slots?.length || !selectedRange}
>
Pokračovat
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,138 @@
import React from 'react';
import { Card, Button, Table, Form } from 'react-bootstrap';
import { useEffect, useState } from 'react';
import orderAPI from '../../api/model/order';
const Step4Summary = ({ formData, onBack, onSubmit, note = '', setNote }) => {
const { selectedSquare, selectedEvent, selectedSlot } = formData;
if (!selectedSquare || !selectedEvent || !selectedSlot || selectedSlot.length === 0) {
return <p>Chybí informace o výběru. Vraťte se zpět a doplňte potřebné údaje.</p>;
}
// Spočítat celkovou cenu všech slotů pomocí API (podobně jako ve Step3Map.jsx)
const [totalPrice, setTotalPrice] = useState(0);
useEffect(() => {
// Volání API pro získání ceny
async function fetchTotalPrice() {
if (!selectedSlot || selectedSlot.length === 0) {
setTotalPrice(0);
return;
}
let total = 0;
for (const s of selectedSlot) {
try {
const data = await orderAPI.calculatePrice({
slot: s.id,
reserved_from: selectedEvent.start,
reserved_to: selectedEvent.end,
used_extension: s.used_extension || 0,
});
total += parseFloat(data.final_price || 0);
} catch {
// fallback: ignore error, continue
}
}
setTotalPrice(total);
}
fetchTotalPrice();
}, [selectedEvent.id, selectedSlot]);
// Helper to calculate reserved days
function getReservedDays(start, end) {
const startDate = new Date(start);
const endDate = new Date(end);
return Math.floor((endDate - startDate) / (1000 * 60 * 60 * 24)) + 1;
}
return (
<Card className="p-4" style={{ background: "rgba(255,255,255,0.7)" }}>
<h3 className="mb-4">🧾 Shrnutí objednávky</h3>
<h5>📍 Náměstí:</h5>
<p><strong>{selectedSquare.name}</strong><br />{selectedSquare.street}, {selectedSquare.city} {selectedSquare.psc}</p>
<h5>📅 Událost:</h5>
<p>
<strong>{selectedEvent.name}</strong><br />
{selectedEvent.start} {selectedEvent.end}<br />
Cena za : <strong>{selectedEvent.price_per_m2} </strong>
</p>
<h5>📦 Vybrané sloty:</h5>
<div className="mb-2 text-muted" style={{ fontSize: "0.95em" }}>
Tabulka níže zobrazuje vybrané sloty, jejich rozměry, cenu za metr čtvereční, počet dní rezervace a vypočtenou cenu za každý slot.
</div>
<Table bordered size="sm" style={{ background: "rgba(255,255,255,0.85)" }}>
<thead>
<tr>
<th>Slot</th>
<th>Detail</th>
<th>Hodnota</th>
</tr>
</thead>
<tbody>
{selectedSlot.map((slot) => {
const pricePerM2 = parseFloat(slot.price_per_m2 || selectedEvent.price_per_m2);
const area = slot.width * slot.height;
const days = getReservedDays(selectedEvent.start, selectedEvent.end);
// const subtotal = area * pricePerM2 * days;
return (
<>
<tr key={`slot-info-${slot.id}`}>
<td rowSpan={3}><strong>{slot.number}</strong></td>
<td>Rozměry (šířka × výška)</td>
<td>{slot.width} × {slot.height} m = <strong>{area} </strong></td>
</tr>
<tr key={`slot-days-${slot.id}`}>
<td>Počet dní</td>
<td>{days}</td>
</tr>
<tr key={`slot-price-m2-${slot.id}`}>
<td>Cena za </td>
<td>{pricePerM2.toFixed(2)} </td>
</tr>
</>
);
})}
</tbody>
<tfoot>
<tr>
<td colSpan={2} className="text-end">
<strong>Celková cena objednávky:</strong>
</td>
<td><strong>{totalPrice.toFixed(2)} </strong></td>
</tr>
</tfoot>
</Table>
{/* Note input (optional) using Bootstrap */}
<Form.Group className="mb-3" controlId="note-field">
<Form.Label>
<small className="text-muted">Poznámka (volitelné)</small>
</Form.Label>
<Form.Control
as="textarea"
value={note}
onChange={e => setNote && setNote(e.target.value)}
placeholder="Zde můžete přidat poznámku k objednávce..."
rows={3}
/>
</Form.Group>
<div className="d-flex justify-content-between">
<Button variant="secondary" onClick={onBack}>
Zpět
</Button>
<Button variant="success" onClick={onSubmit}>
Potvrdit a odeslat objednávku
</Button>
</div>
</Card>
);
};
export default Step4Summary;

View File

@@ -0,0 +1,219 @@
import '@mantine/core/styles.layer.css'; // základní styly komponent
import '@mantine/dates/styles.css'; // styly pro kalendář
import { useState } from "react";
import { SegmentedControl, Group } from "@mantine/core";
import { DatePicker } from "@mantine/dates";
import dayjs from "dayjs";
import { Container, Row, Col } from 'react-bootstrap';
/**
* Komponenta pro výběr rozsahu rezervace s vizuálním označením rezervovaných dní a omezením na povolený interval.
*/
export default function DaySelectorCalendar({
onSelectDate,
bookedRanges = [],
eventStart,
eventEnd,
defaultDate,
}) {
const [range, setRange] = useState([null, null]);
const [mode, setMode] = useState("manual");
const [quickStart, setQuickStart] = useState(null);
const [quickType, setQuickType] = useState("day");
const normalizeMinDate = (d) => dayjs(d).startOf("day").toDate();
const normalizeMaxDate = (d) => dayjs(d).endOf("day").toDate();
// Helper to check if a date is reserved
const isReserved = (date) => {
// bookedRanges is now array of dates (string or Date)
return bookedRanges.some((reserved) => {
// Normalize both to YYYY-MM-DD for comparison
const d = dayjs(date).format("YYYY-MM-DD");
const r = dayjs(reserved).format("YYYY-MM-DD");
return d === r;
});
};
const isOutOfBounds = (date) => {
if (eventStart && dayjs(date).isBefore(dayjs(eventStart), "day")) return true;
if (eventEnd && dayjs(date).isAfter(dayjs(eventEnd), "day")) return true;
return false;
};
const getQuickRange = (start, type) => {
if (!start) return [null, null];
const d = dayjs(start);
if (type === "day") return [d.toDate(), d.toDate()];
if (type === "week") return [d.startOf("week").toDate(), d.endOf("week").toDate()];
if (type === "month") return [d.startOf("month").toDate(), d.endOf("month").toDate()];
return [null, null];
};
const handleChange = (value) => {
let normalized = Array.isArray(value) ? [value[0] ?? null, value[1] ?? null] : [null, null];
setRange(normalized);
if (normalized[0] && normalized[1]) {
onSelectDate?.({ start: normalized[0], end: normalized[1] });
}
};
const handleQuickPick = (date) => {
setQuickStart(date);
const [start, end] = getQuickRange(date, quickType);
setRange([start, end]);
if (start && end) onSelectDate?.({ start, end });
};
const handleQuickTypeChange = (type) => {
setQuickType(type);
if (quickStart) {
const [start, end] = getQuickRange(quickStart, type);
setRange([start, end]);
if (start && end) onSelectDate?.({ start, end });
}
};
const handleModeChange = (val) => {
setMode(val);
setRange([null, null]);
setQuickStart(null);
};
return (
<div className='bg-white p-4 rounded shadow-sm'>
<Group mb="sm">
<Container>
<Row>
<h5>Režim kalendáře:</h5>
</Row>
<Row>
<Col>
<SegmentedControl
value={mode}
onChange={handleModeChange}
data={[
{ label: "Vybrat rozsah", value: "manual" },
{ label: "Rychlý výběr", value: "quick" },
]}
/>
</Col>
{mode === "quick" && (
<Col>
<SegmentedControl
value={quickType}
onChange={handleQuickTypeChange}
data={[
{ label: "Den", value: "day" },
{ label: "Týden", value: "week" },
{ label: "Měsíc", value: "month" },
]}
/>
</Col>
)}
</Row>
</Container>
</Group>
<DatePicker
key={mode} // reset při změně režimu
presets={[
{ value: dayjs().subtract(1, 'day').format('YYYY-MM-DD'), label: 'Včera' },
{ value: dayjs().format('YYYY-MM-DD'), label: 'Dnes' },
{ value: dayjs().add(1, 'day').format('YYYY-MM-DD'), label: 'Zítra' },
{ value: dayjs().add(1, 'month').format('YYYY-MM-DD'), label: 'Příští měsíc' },
{ value: dayjs().add(1, 'year').format('YYYY-MM-DD'), label: 'Příští rok' },
{ value: dayjs().subtract(1, 'month').format('YYYY-MM-DD'), label: 'Minulý měsíc' },
{ value: dayjs().subtract(1, 'year').format('YYYY-MM-DD'), label: 'Minulý rok' },
]}
allowDeselect
allowSingleDateInRange
type={mode === "manual" ? "range" : "default"}
value={mode === "manual" ? range : quickStart}
onChange={mode === "manual" ? handleChange : handleQuickPick}
minDate={eventStart ? normalizeMinDate(eventStart) : undefined}
maxDate={eventEnd ? normalizeMaxDate(eventEnd) : undefined}
defaultDate={defaultDate ? dayjs(defaultDate).toDate() : undefined} // <-- set initial displayed date
getDayProps={(date) => {
if (isReserved(date)) {
return {
style: {
backgroundColor: "#f8d7da",
color: "#721c24",
borderRadius: 4,
},
disabled: true,
};
}
if (isOutOfBounds(date)) {
return { disabled: true, style: { opacity: 0.5 } };
}
if (
mode === "quick" &&
quickStart &&
range[0] &&
range[1] &&
(
(dayjs(date).isAfter(dayjs(range[0]), "day") || dayjs(date).isSame(dayjs(range[0]), "day")) &&
(dayjs(date).isBefore(dayjs(range[1]), "day") || dayjs(date).isSame(dayjs(range[1]), "day"))
)
) {
return {
style: {
backgroundColor: "#e3f6fc",
color: "#186fa7",
borderRadius: 4,
},
};
}
return {};
}}
/>
<div className="mt-3 text-sm text-muted">
<strong>Legenda:</strong>
<ul className="list-disc ml-5 mt-1">
<li>
<span
style={{
color: "#721c24",
background: "#f8d7da",
borderRadius: 2,
padding: "0 4px",
}}
>
Červená
</span>
: Rezervováno
</li>
<li>
<span
style={{
color: "#186fa7",
background: "#e3f6fc",
borderRadius: 2,
padding: "0 4px",
}}
>
Modrá
</span>
: Vybraný rozsah (rychlý výběr)
</li>
<li>
<span className="text-gray-800">Bez barvy</span>: Volno
</li>
</ul>
</div>
{range[0] && range[1] && (
<div className="mt-2">
Vybráno: {dayjs(range[0]).format("D. M. YYYY")} {dayjs(range[1]).format("D. M. YYYY")}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,149 @@
import React, { useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import axios from "axios";
import {
Form,
Button,
Card,
Alert,
Spinner,
Row,
Col,
} from "react-bootstrap";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faKey } from "@fortawesome/free-solid-svg-icons";
const CreateNewPassoword = () => {
const { uidb64, token } = useParams();
const navigate = useNavigate();
const [newPassword, setNewPassword] = useState("");
const [reNewPassword, setReNewPassword] = useState("");
const [status, setStatus] = useState("idle"); // idle | loading | success | error
const [error, setError] = useState("");
const handleSubmit = async (e) => {
e.preventDefault();
setStatus("loading");
setError("");
if (newPassword !== reNewPassword) {
setStatus("error");
setError("Hesla se neshodují.");
return;
}
try {
const response = await axios.post(
`/api/account/reset-password/${uidb64}/${token}/`,
{
new_password: newPassword,
re_new_password: reNewPassword,
}
);
if (response.status === 200) {
setStatus("success");
setTimeout(() => {
navigate("/"); // přesměrování na login page
}, 3000);
}
} catch (err) {
setStatus("error");
setError(
err.response?.data?.detail ||
"Nepodařilo se resetovat heslo. Token může být neplatný nebo expirovaný."
);
}
};
return (
<Card className="align-self-center mt-5" style={{ maxWidth: "420px" }}>
<Card.Header>
<h3>Reset hesla</h3>
</Card.Header>
<Card.Body>
{status === "success" ? (
<Alert variant="success">
Heslo bylo úspěšně změněno. Přesměrování na přihlášení...
</Alert>
) : (
<Form onSubmit={handleSubmit}>
{/* Nové heslo */}
<Form.Group className="input-group form-group" controlId="newPassword">
<Form.Group className="input-group-prepend">
<span className="input-group-text">
<FontAwesomeIcon icon={faKey} />
</span>
</Form.Group>
<Form.Label hidden>Nové heslo</Form.Label>
<Form.Control
type="password"
required
autoComplete="new-password"
placeholder="Nové heslo"
name="newPassword"
onChange={(e) => setNewPassword(e.target.value)}
value={newPassword}
/>
</Form.Group>
{/* Potvrzení hesla */}
<Form.Group className="input-group form-group mt-3" controlId="reNewPassword">
<Form.Group className="input-group-prepend">
<span className="input-group-text">
<FontAwesomeIcon icon={faKey} />
</span>
</Form.Group>
<Form.Label hidden>Potvrdit heslo</Form.Label>
<Form.Control
type="password"
required
autoComplete="new-password"
placeholder="Potvrdit heslo"
name="reNewPassword"
onChange={(e) => setReNewPassword(e.target.value)}
value={reNewPassword}
/>
</Form.Group>
{/* Submit */}
<Form.Group className="form-group">
<Button
type="submit"
className="float-right login_btn mt-3"
disabled={status === "loading"}
>
{status === "loading" ? (
<>
<Spinner animation="border" size="sm" /> Odesílání...
</>
) : (
"Resetovat heslo"
)}
</Button>
</Form.Group>
{/* Chyba */}
{status === "error" && (
<Alert variant="danger" className="mt-3">
{error}
</Alert>
)}
</Form>
)}
</Card.Body>
<Card.Footer>
<Row>
<Col className="text-center">
<a href="/">Zpět na přihlášení</a>
</Col>
</Row>
</Card.Footer>
</Card>
);
};
export default CreateNewPassoword;

View File

@@ -0,0 +1,109 @@
import React, { useState } from "react";
import {
Form,
Button,
Card,
Alert,
Spinner,
Row,
Col,
} from "react-bootstrap";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faEnvelope } from "@fortawesome/free-solid-svg-icons";
import { apiRequest } from "../../api/auth";
const ResetPasswordRequest = () => {
const [email, setEmail] = useState("");
const [status, setStatus] = useState("idle"); // idle | loading | success | error
const [error, setError] = useState("");
const handleSubmit = async (e) => {
e.preventDefault();
setStatus("loading");
setError("");
try {
await apiRequest("post", "/account/reset-password/", { email });
setStatus("success");
} catch (err) {
// Pokud apiRequest vrací error jako objekt, zkus ho správně zachytit
const message =
err?.response?.data?.detail ||
err?.message ||
"Nepodařilo se odeslat požadavek. Zkuste to prosím znovu.";
setError(message);
setStatus("error");
}
};
return (
<Card className="align-self-center mt-5" style={{ maxWidth: "420px" }}>
<Card.Header>
<h3>Obnovení hesla</h3>
</Card.Header>
<Card.Body>
{status === "success" ? (
<Alert variant="success">Odeslán email s instrukcemi.</Alert>
) : (
<Form onSubmit={handleSubmit}>
<Form.Group className="input-group form-group" controlId="formEmail">
<Form.Group className="input-group-prepend">
<span className="input-group-text">
<FontAwesomeIcon icon={faEnvelope} />
</span>
</Form.Group>
<Form.Label hidden>Email</Form.Label>
<Form.Control
type="email"
placeholder="Zadejte váš email"
required
autoComplete="email"
autoFocus
name="email"
onChange={(e) => setEmail(e.target.value)}
value={email}
/>
</Form.Group>
<Form.Group className="form-group d-flex justify-content-center">
<Button
type="submit"
style={{ width: "fit-content" }}
className="float-right login_btn mt-3 d-inline-flex"
disabled={status === "loading"}
>
{status === "loading" ? (
<>
<Spinner animation="border" size="sm" /> Odesílání...
</>
) : (
"Obnovit heslo"
)}
</Button>
</Form.Group>
{status === "error" && (
<Alert variant="danger" className="mt-3">
{error}
</Alert>
)}
</Form>
)}
</Card.Body>
<Card.Footer>
<Row>
<Col className="text-center">
<a href="/">Zpět na přihlášení</a>
</Col>
</Row>
</Card.Footer>
</Card>
);
};
export default ResetPasswordRequest;

View File

@@ -0,0 +1,161 @@
<Container fluid className="py-3">
<Row>
<Col md={8}>
{/* Legend */}
<Stack direction="horizontal" gap={2} className="mb-3">
<Badge bg="success">
Aktivní
</Badge>
<Badge bg="warning">
Rezervováno
</Badge>
<Badge bg="danger">
Blokováno
</Badge>
</Stack>
{/* Action Buttons */}
<Stack direction="horizontal" gap={2} className="mb-3">
<Button
variant="danger"
size="sm"
onClick={() => handleDeleteReservation(selectedIndex)}
disabled={selectedIndex === null}
>
<i className="bi bi-trash me-1"></i> Smazat vybranou
</Button>
<Button
variant="warning"
size="sm"
onClick={handleClearAll}
disabled={reservations.length === 0}
>
<i className="bi bi-x-circle me-1"></i> Smazat vše
</Button>
<Button
variant="success"
size="sm"
onClick={generateReservationJson}
disabled={reservations.length === 0}
>
<i className="bi bi-download me-1"></i> Uložit jako JSON
</Button>
</Stack>
{/* Grid Container */}
<div
ref={gridRef}
className="position-relative border"
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onContextMenu={(e) => e.preventDefault()}
style={{
width: cols * cellSize,
height: rows * cellSize,
display: "grid",
gridTemplateColumns: `repeat(${cols}, ${cellSize}px)`,
gridTemplateRows: `repeat(${rows}, ${cellSize}px)`,
}}
>
{gridCells}
{/* Reservation Boxes */}
{reservations.map((res, i) => (
<div
key={i}
data-index={i}
className={`reservation ${res.status} ${
draggedIndex === i ? "dragging" : ""
}`}
onContextMenu={(e) => {
e.preventDefault();
handleDeleteReservation(i);
}}
style={{
position: "absolute",
left: res.x * cellSize,
top: res.y * cellSize,
width: res.w * cellSize,
height: res.h * cellSize,
backgroundColor: statusColors[res.status],
border: i === selectedIndex ? "2px solid black" : "none",
fontSize: 12,
textAlign: "center",
transition:
draggedIndex === i || resizingIndex === i
? "none"
: "all 0.2s ease",
zIndex: 2,
}}
>
<div className="d-flex flex-column h-100 p-1">
<div className="flex-grow-1 d-flex align-items-center justify-content-center">
<strong>{i + 1}</strong>
</div>
<Form.Select
size="sm"
value={res.status}
onChange={(e) => handleStatusChange(i, e.target.value)}
onClick={(e) => e.stopPropagation()}
>
<option value="active">Aktivní</option>
<option value="reserved">Rezervováno</option>
<option value="blocked">Blokováno</option>
</Form.Select>
</div>
<div
className="resize-handle"
style={{
position: "absolute",
right: 0,
bottom: 0,
width: 10,
height: 10,
backgroundColor: "white",
border: "1px solid black",
cursor: "nwse-resize",
}}
/>
</div>
))}
{/* Draft preview box */}
{isDragging && startCell && hoverCell && (
<div
className="reservation-draft"
style={{
position: "absolute",
left: Math.min(startCell.x, hoverCell.x) * cellSize,
top: Math.min(startCell.y, hoverCell.y) * cellSize,
width: (Math.abs(startCell.x - hoverCell.x) + 1) * cellSize,
height: (Math.abs(startCell.y - hoverCell.y) + 1) * cellSize,
backgroundColor: "rgba(0, 128, 0, 0.3)",
pointerEvents: "none",
zIndex: 1,
}}
/>
)}
</div>
{/* File Upload */}
<Form.Group className="mt-3">
<Form.Label>Nahrát rezervace ze souboru:</Form.Label>
<Form.Control
type="file"
size="sm"
accept=".json"
onChange={(e) => {
const file = e.target.files[0];
if (!file) return;
handleFileUpload(file);
e.target.value = ""; // Reset input
}}
/>
</Form.Group>
</Col>
</Row>
</Container>

View File

@@ -0,0 +1,56 @@
// /components/RequireAuthLayout.jsx
import { Outlet, useNavigate, useLocation } from "react-router-dom";
import { useEffect, useState, useContext } from "react";
import { getCurrentUser } from "../../api/auth";
import { UserContext } from "../../context/UserContext";
// Layout which ensures user is authenticated before rendering nested routes.
// Issues fixed:
// 1. Previously ignored the fetched currentUser and used stale context (always null on first load) causing redirects.
// 2. setUser was called with "user" instead of the fetched "currentUser".
// 3. Navigation happened before context could be populated, causing protected pages to crash accessing user.role.
export default function RequireAuthLayout() {
const [checking, setChecking] = useState(true);
const navigate = useNavigate();
const location = useLocation();
const { user, setUser } = useContext(UserContext);
useEffect(() => {
let cancelled = false;
const check = async () => {
try {
const currentUser = await getCurrentUser();
if (!currentUser) {
// Not authenticated -> go to login
navigate("/login", {
state: { from: location.pathname },
replace: true,
});
return;
}
if (!cancelled) {
setUser(currentUser); // store fetched user in context
}
} catch (err) {
// 401 or network error -> treat as unauthenticated
navigate("/login", {
state: { from: location.pathname },
replace: true,
});
} finally {
if (!cancelled) setChecking(false);
}
};
check();
return () => { cancelled = true; };
// Run on initial mount & path change (e.g. deep link) not on user change to avoid loop.
}, [location.pathname, navigate, setUser]);
// While checking auth, render nothing (or a small loader placeholder if desired)
if (checking) return null;
// If user somehow missing after check (edge race) redirect handled above; return null to avoid crashes.
if (!user) return null;
return <Outlet />;
}

View File

@@ -0,0 +1,32 @@
import { useNavigate, useLocation, Outlet } from "react-router-dom";
import { useEffect, useState } from "react";
import { useContext } from "react";
import { UserContext } from "../../context/UserContext";
export default function RequireRole({ roles = [] }) {
const { user } = useContext(UserContext) || {};
const [allowed, setAllowed] = useState(null); // null = loading
const navigate = useNavigate();
const location = useLocation();
useEffect(() => {
if (!user) {
navigate("/login", { state: { from: location.pathname } });
return;
}
const userRoles = Array.isArray(user.role) ? user.role : [user.role];
const hasRole = roles.some((role) => userRoles.includes(role));
if (!hasRole) {
navigate("/unauthorized");
} else {
setAllowed(true);
}
}, [user, roles, navigate, location.pathname]);
//if (allowed === null) return <p>Kontroluji oprávnění...</p>;
return allowed ? <Outlet /> : null;
}

View File

@@ -0,0 +1,30 @@
// /context/UserContext.jsx
import { createContext, useState } from 'react';
export const UserContext = createContext({
user: null,
setUser: () => {},
});
export function UserProvider({ children }) {
const [user, setUser] = useState(null);
return (
<UserContext.Provider value={{ user, setUser }}>
{children}
</UserContext.Provider>
);
}
/*
POUŽIJ TOHLE PRO ZÍSKANÍ USERA:
import { useContext } from "react";
import { UserContext } from "../context/UserContext"; <-- CESTA K TOMUHLE SOUBORU
const { user } = useContext(UserContext) || {};
*/

18109
frontend/src/css/index.css Normal file

File diff suppressed because it is too large Load Diff

18
frontend/src/main.jsx Normal file
View File

@@ -0,0 +1,18 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import "./css/index.css";
import "@mantine/core/styles.layer.css";
import App from "./App.jsx";
import { MantineProvider } from "@mantine/core";
//import 'bootstrap/dist/css/bootstrap.min.css';
createRoot(document.getElementById("app")).render(
<StrictMode>
<BrowserRouter>
<MantineProvider withGlobalStyles withNormalizeCSS>
<App />
</MantineProvider>
</BrowserRouter>
</StrictMode>
);

View File

@@ -0,0 +1,11 @@
function Admin(){
return(
<div>
<header>Admin page</header>
</div>
)
}
export default Admin

View File

@@ -0,0 +1,17 @@
import {Container, Button, Card, Row, Col} from "react-bootstrap";
import ReportForm from "../components/ReportForm";
function ReportForm() {
return (
<Container fluid className="flex-grow-1 login-bg py-5">
<div className="d-flex flex-column justify-content-center h-100">
<ReportForm />
</div>
<div className="m-auto ">
<h2 className="text-center my-5 text-white fw-semibold">eTržnice</h2>
</div>
</Container>
);
}
export default ReportForm;

674
frontend/src/pages/Home.jsx Normal file
View File

@@ -0,0 +1,674 @@
import {
Container, Row, Col,
Card,
Badge,
Button,
Tabs, Tab,
Modal, Form, Alert
} from "react-bootstrap";
import { faGear, faPlus } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link } from "react-router-dom";
import { UserContext } from "../context/UserContext";
import { useState, useEffect, useContext } from "react";
import dayjs from "dayjs";
import Table from "../components/Table";
import ordersAPI from "../api/model/order";
import reservationsAPI from "../api/model/reservation";
import ticketsAPI from "../api/model/ticket";
import { IconEye, IconEdit, IconTrash, IconCreditCard } from "@tabler/icons-react";
import { Group, ActionIcon, Text, Stack } from "@mantine/core";
import { useNavigate } from "react-router-dom";
function Home() {
const { user } = useContext(UserContext) || {};
// Guard: until RequireAuthLayout sets user, avoid rendering (prevents user.role null errors)
if (!user) {
return null; // or return a loader component
}
const [user_reservations, setReservations] = useState([]);
const [user_orders, setOrders] = useState([]);
const [user_tickets, setTickets] = useState([]);
const navigate = useNavigate();
useEffect(() => {
const fetchReservations = async () => {
try {
var data = await reservationsAPI.getReservations({ user: user.id });
setReservations(data);
data = undefined;
data = await ordersAPI.getOrders({ user: user.id });
setOrders(data);
data = undefined;
data = await ticketsAPI.getServiceTickets({ user: user.id });
setTickets(data); // <-- FIX: was setOrders(data)
} catch (err) {
console.error("Chyba při načítání:", err);
}
};
if (user?.id) {
fetchReservations();
}
}, [user?.id]);
// Modal state
const [showModal, setShowModal] = useState(false);
const [modalType, setModalType] = useState(""); // 'view', 'edit'
const [selectedRecord, setSelectedRecord] = useState(null);
const [formData, setFormData] = useState({});
const [error, setError] = useState(null);
const [submitting, setSubmitting] = useState(false);
// Reservation actions
const handleShowReservation = (record) => {
setSelectedRecord(record);
setModalType("view-reservation");
setShowModal(true);
};
const handleEditReservation = (record) => {
setSelectedRecord(record);
setFormData({
...record,
// Add more fields if needed
});
setModalType("edit-reservation");
setShowModal(true);
setError(null);
};
const handleDeleteReservation = async (record) => {
if (window.confirm(`Opravdu smazat rezervaci #${record.id}?`)) {
// Implement delete API call here
// await reservationAPI.deleteReservation(record.id);
setReservations((prev) => prev.filter(r => r.id !== record.id));
}
};
// Order actions
const handleShowOrder = (record) => {
setSelectedRecord(record);
setModalType("view-order");
setShowModal(true);
};
const handleEditOrder = (record) => {
setSelectedRecord(record);
setFormData({
...record,
// Add more fields if needed
});
setModalType("edit-order");
setShowModal(true);
setError(null);
};
const handleDeleteOrder = async (record) => {
if (window.confirm(`Opravdu smazat objednávku #${record.id}?`)) {
// Implement delete API call here
// await orderAPI.deleteOrder(record.id);
setOrders((prev) => prev.filter(r => r.id !== record.id));
}
};
const handlePayOrder = (record) => {
navigate(`/payment/${record.id}`);
};
// Ticket actions
const handleShowTicket = (record) => {
setSelectedRecord(record);
setModalType("view-ticket");
setShowModal(true);
};
const handleEditTicket = (record) => {
setSelectedRecord(record);
setFormData({
...record,
// Add more fields if needed
});
setModalType("edit-ticket");
setShowModal(true);
setError(null);
};
const handleDeleteTicket = async (record) => {
if (window.confirm(`Opravdu smazat ticket #${record.id}?`)) {
// Implement delete API call here
// await ticketAPI.deleteTicket(record.id);
setTickets((prev) => prev.filter(r => r.id !== record.id));
}
};
// Edit modal submit handlers (example for reservation)
const handleEditModalSubmit = async (e) => {
e.preventDefault();
setError(null);
setSubmitting(true);
try {
// Implement update API call here, e.g.:
// await reservationAPI.updateReservation(selectedRecord.id, formData);
setShowModal(false);
// Refresh data if needed
} catch (err) {
setError("Chyba při ukládání: " + (err.message || "Neznámá chyba"));
} finally {
setSubmitting(false);
}
};
const reservation_columns = [
{ accessor: "id", title: "ID", sortable: true },
{ accessor: "event.name", title: "Událost", sortable: true },
{ accessor: "marketSlot", title: "Slot", sortable: true },
{ accessor: "used_extension", title: "Prodlouženo", sortable: true },
{
accessor: "reserved_from",
title: "Od",
sortable: true,
render: ({ reserved_from }) => dayjs(reserved_from).format("DD.MM.YYYY HH:mm"),
},
{
accessor: "reserved_to",
title: "Do",
sortable: true,
render: ({ reserved_to }) => dayjs(reserved_to).format("DD.MM.YYYY HH:mm"),
},
{
accessor: "final_price",
title: "Cena",
sortable: true,
render: ({ final_price }) => `${Number(final_price).toFixed(2)}`,
},
{ accessor: "status", title: "Stav", sortable: true },
{
accessor: "actions",
title: "Akce",
width: "5.5%",
render: (record) => (
<Group gap={4} wrap="nowrap">
<ActionIcon size="sm" variant="subtle" color="green" onClick={() => handleShowReservation(record)}>
<IconEye size={16} />
</ActionIcon>
<ActionIcon size="sm" variant="subtle" color="blue" onClick={() => handleEditReservation(record)}>
<IconEdit size={16} />
</ActionIcon>
<ActionIcon size="sm" variant="subtle" color="red" onClick={() => handleDeleteReservation(record)}>
<IconTrash size={16} />
</ActionIcon>
</Group>
),
},
];
const order_columns = [
{ accessor: "id", title: "ID", sortable: true },
{
accessor: "reservation.name",
title: "Rezervace",
sortable: true,
},
{
accessor: "created_at",
title: "Vytvořeno",
sortable: true,
render: ({ created_at }) => dayjs(created_at).format("DD.MM.YYYY HH:mm"),
},
{
accessor: "price_to_pay",
title: "Částka k zaplacení",
sortable: true,
render: ({ price_to_pay }) => `${Number(price_to_pay).toFixed(2)}`,
},
{
accessor: "payed_at",
title: "Zaplaceno v čase",
sortable: true,
render: ({ payed_at }) => payed_at ? dayjs(payed_at).format("DD.MM.YYYY HH:mm") : "-",
},
{
accessor: "status",
title: "Stav",
sortable: true,
},
{
accessor: "note",
title: "Poznámka",
sortable: false,
render: ({ note }) => note?.slice(0, 50) || "-",
},
{
accessor: "actions",
title: "Akce",
width: "7%",
render: (record) => (
<Group gap={4} wrap="nowrap">
<ActionIcon size="sm" variant="subtle" color="green" onClick={() => handleShowOrder(record)}>
<IconEye size={16} />
</ActionIcon>
<ActionIcon size="sm" variant="subtle" color="orange" onClick={() => handlePayOrder(record)} title="Zaplatit">
<IconCreditCard size={16} />
</ActionIcon>
</Group>
),
},
];
const ticket_columns = [
{ accessor: "id", title: "ID", sortable: true },
{ accessor: "title", title: "Název", sortable: true },
{
accessor: "created_at",
title: "Vytvořeno",
sortable: true,
render: ({ created_at }) => dayjs(created_at).format("DD.MM.YYYY HH:mm"),
},
{ accessor: "status", title: "Stav", sortable: true },
{ accessor: "category", title: "Kategorie", sortable: true },
{
accessor: "description",
title: "Popis",
sortable: false,
render: ({ description }) => description?.slice(0, 50) || "-",
},
{
accessor: "actions",
title: "Akce",
width: "5.5%",
render: (record) => (
<Group gap={4} wrap="nowrap">
<ActionIcon size="sm" variant="subtle" color="green" onClick={() => handleShowTicket(record)}>
<IconEye size={16} />
</ActionIcon>
<ActionIcon size="sm" variant="subtle" color="blue" onClick={() => handleEditTicket(record)}>
<IconEdit size={16} />
</ActionIcon>
<ActionIcon size="sm" variant="subtle" color="red" onClick={() => handleDeleteTicket(record)}>
<IconTrash size={16} />
</ActionIcon>
</Group>
),
},
];
const RezervaceModalContent = (record, close) => (
<Stack gap="xs">
<Text fw={700}>Detail rezervace</Text>
<Text>Událost: {record.event?.name}</Text>
<Text>Slot: {record.marketSlot}</Text>
<Text>Prodlouženo: {record.used_extension ? "Ano" : "Ne"}</Text>
<Text>Od: {dayjs(record.reserved_from).format("DD.MM.YYYY HH:mm")}</Text>
<Text>Do: {dayjs(record.reserved_to).format("DD.MM.YYYY HH:mm")}</Text>
<Text>Stav: {record.status}</Text>
<Text>Poznámka: {record.note}</Text>
<Text>Cena: {Number(record.final_price).toFixed(2)} </Text>
<Group justify="end" mt="sm">
<Button onClick={close} variant="light">Zavřít</Button>
</Group>
</Stack>
);
const OrderModalContent = (record, close) => (
<Stack gap="xs">
<Text fw={700}>Detail Objednávky</Text>
<Text>Rezervace: {record.reservation?.name}</Text>
<Text>Vytvořeno: {dayjs(record.created_at).format("DD.MM.YYYY HH:mm")}</Text>
<Text>Částka k zaplacení: {Number(record.price_to_pay).toFixed(2)} </Text>
<Text>Zaplaceno v čase: {record.payed_at ? dayjs(record.payed_at).format("DD.MM.YYYY HH:mm") : "-"}</Text>
<Text>Stav: {record.status}</Text>
<Text>Poznámka: {record.note}</Text>
<Group justify="end" mt="sm">
<Button onClick={close} variant="light">Zavřít</Button>
<Button variant="success" onClick={() => handlePayOrder(record)}>Zaplatit</Button>
</Group>
</Stack>
);
const TicketModalContent = (record, close) => (
<Stack gap="xs">
<Text fw={700}>Detail Objednávky</Text>
<Text>Název: {record.title}</Text>
<Text>Vytvořeno: {record.created_at.format("DD.MM.YYYY HH:mm")}</Text>
<Text>Stav: {record.status}</Text>
<Text>Kategorie: {record.category}</Text>
<Text>Popis: {record.description}</Text>
<Group justify="end" mt="sm">
<Button onClick={close} variant="light">Zavřít</Button>
</Group>
</Stack>
);
return (
<Container
fluid
className="justify-content-center mt-5"
style={{ height: "100vh" }}
>
<Row >
<Col>
<Card className="shadow position-relative mx-5">
{/* Badge s rolí v pravém horním rohu */}
<Badge
bg={
user.role === "admin"
? "danger"
: user.role === "seller"
? "success"
: "Info"
}
text="white" // bílý text
className="fs-4 position-absolute top-0 start-0 m-2"
style={{ fontSize: "0.8rem", zIndex: 1 , right: "0"}}
>
{
user.role === "admin"
? "Admin"
: user.role === "seller"
? "Prodejce"
: user.role === "squareManager"
? "Správce tržiště"
: user.role === "cityClerk"
? "Úředník"
: user.role === "checker"
? "Kontrolor"
: "Neznámá role"
}
</Badge>
<br />
<Card.Body>
<Card.Title className="mb-3">
</Card.Title>
<Container>
<Row>
<Col>Přihlášen jako: <strong>{user.username}</strong></Col>
<Col>{user.account_type}</Col>
</Row>
<hr />
<Row className="mb-2">
<Col><strong>Jméno:</strong> {user.first_name}</Col>
<Col><strong>Příjmení:</strong> {user.last_name}</Col>
</Row>
<hr />
<h4 className="text-secondary">Adresa</h4>
<Row className="mb-2">
<Col><strong>Město:</strong> {user.city}</Col>
<Col><strong>Ulice:</strong> {user.street}</Col>
</Row>
<Row>
<Col><strong>PSČ:</strong> {user.PSC}</Col>
</Row>
<hr />
<h4 className="text-secondary">Kontaktní údaje</h4>
<Row>
<Col><strong>Tel:</strong> {user.phone_number}</Col>
</Row>
<Row>
<Col><strong>E-mail:</strong> {user.email}</Col>
</Row>
<hr />
<h4 className="text-secondary">Platby</h4>
<Row>
<Col><strong>Účet:</strong> {user.bank_account}</Col>
</Row>
<Row>
<Col><strong>Variabilní číslo:</strong> {user.var_symbol}</Col>
</Row>
</Container>
<div className="d-flex justify-content-between mt-4">
<Link to="/settings" className="fs-4 btn btn-outline-secondary border-0">
<FontAwesomeIcon icon={faGear} className="me-2" />
</Link>
</div>
</Card.Body>
</Card>
</Col>
<Col>
{/* Buttons */}
<Container>
<Row>
<Link to="/create-reservation" className="btn btn-success fs-3 mb-3">
<FontAwesomeIcon icon={faPlus} className="mr-2" />
Vytvořit Rezervaci
</Link>
</Row>
<Row>
<Link to="/tickets" className="btn btn-danger fs-3 mb-3">
Problém?
</Link>
</Row>
{
user.role === "admin" ? (
<Row>
<Link to="/manage/squares" className="btn btn-danger fs-3">
Manager
</Link>
</Row>
) : user.role === "seller" ? (
""
) : user.role === "squareManager" ? (
<Row>
<Link to="/manage/squares" className="btn btn-danger fs-3">
Manager
</Link>
</Row>
) : user.role === "cityClerk" ? (
<Row>
<Link to="/manage/squares" className="btn btn-danger fs-3">
Manager
</Link>
</Row>
) : user.role === "checker" ? (
""
) : (
"Neznámá role"
)
}
</Container>
</Col>
</Row>
{/* TAB TABULKY */}
<Row className="my-5 mx-5 bg-white">
<Tabs defaultActiveKey="reservations" id="user-data-tabs" className="my-4 mx-2 d-flex">
<Tab id="tabusek" eventKey="reservations" title="Rezervace">
<Table
data={user_reservations}
columns={reservation_columns}
defaultSort="id"
modalTitle="Detail rezervace"
renderModalContent={RezervaceModalContent}
withGlobalSearch
/>
</Tab>
<Tab eventKey="orders" title="Objednávky">
<Table
data={user_orders}
columns={order_columns}
defaultSort="id"
modalTitle="Detail objednávky"
renderModalContent={OrderModalContent}
withGlobalSearch
/>
</Tab>
{user_tickets && (
<Tab eventKey="tickets" title="Tickety">
<Table
data={user_tickets}
columns={ticket_columns}
defaultSort="id"
modalTitle="Detail Ticketu"
renderModalContent={TicketModalContent}
withGlobalSearch
/>
</Tab>
)}
</Tabs>
</Row>
{/* Bootstrap Modal for view/edit */}
<Modal show={showModal && modalType === "view-reservation"} onHide={() => setShowModal(false)} centered>
<Modal.Header closeButton>
<Modal.Title>Detail rezervace</Modal.Title>
</Modal.Header>
<Modal.Body>
{selectedRecord && (
<>
<p><strong>ID:</strong> {selectedRecord.id}</p>
<p><strong>Událost:</strong> {selectedRecord.event?.name}</p>
<p><strong>Slot:</strong> {selectedRecord.marketSlot}</p>
<p><strong>Prodlouženo:</strong> {selectedRecord.used_extension ? "Ano" : "Ne"}</p>
<p><strong>Od:</strong> {dayjs(selectedRecord.reserved_from).format("DD.MM.YYYY HH:mm")}</p>
<p><strong>Do:</strong> {dayjs(selectedRecord.reserved_to).format("DD.MM.YYYY HH:mm")}</p>
<p><strong>Stav:</strong> {selectedRecord.status}</p>
<p><strong>Poznámka:</strong> {selectedRecord.note}</p>
<p><strong>Cena:</strong> {Number(selectedRecord.final_price).toFixed(2)} </p>
</>
)}
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={() => setShowModal(false)}>Zavřít</Button>
<Button variant="primary" onClick={() => { setShowModal(false); handleEditReservation(selectedRecord); }}>Upravit</Button>
</Modal.Footer>
</Modal>
<Modal show={showModal && modalType === "edit-reservation"} onHide={() => setShowModal(false)} centered>
<Modal.Header closeButton>
<Modal.Title>Upravit rezervaci</Modal.Title>
</Modal.Header>
<Form onSubmit={handleEditModalSubmit}>
<Modal.Body>
<Form.Group className="mb-3">
<Form.Label>Poznámka</Form.Label>
<Form.Control
name="note"
value={formData.note || ""}
onChange={e => setFormData(f => ({ ...f, note: e.target.value }))}
/>
</Form.Group>
{/* Add more editable fields as needed */}
{error && <Alert variant="danger">{error}</Alert>}
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={() => setShowModal(false)}>Zrušit</Button>
<Button type="submit" variant="primary" disabled={submitting}>Uložit změny</Button>
</Modal.Footer>
</Form>
</Modal>
<Modal show={showModal && modalType === "view-order"} onHide={() => setShowModal(false)} centered>
<Modal.Header closeButton>
<Modal.Title>Detail objednávky</Modal.Title>
</Modal.Header>
<Modal.Body>
{selectedRecord && (
<>
<p><strong>ID:</strong> {selectedRecord.id}</p>
<p><strong>Rezervace:</strong> {selectedRecord.reservation?.name}</p>
<p><strong>Vytvořeno:</strong> {dayjs(selectedRecord.created_at).format("DD.MM.YYYY HH:mm")}</p>
<p><strong>Částka k zaplacení:</strong> {selectedRecord.price_to_pay}</p>
<p><strong>Zaplaceno v čase:</strong> {selectedRecord.payed_at ? dayjs(selectedRecord.payed_at).format("DD.MM.YYYY HH:mm") : "-"}</p>
<p><strong>Stav:</strong> {selectedRecord.status}</p>
<p><strong>Poznámka:</strong> {selectedRecord.note}</p>
</>
)}
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={() => setShowModal(false)}>Zavřít</Button>
<Button variant="success" onClick={() => handlePayOrder(selectedRecord)}>Zaplatit</Button>
</Modal.Footer>
</Modal>
<Modal show={showModal && modalType === "edit-order"} onHide={() => setShowModal(false)} centered>
<Modal.Header closeButton>
<Modal.Title>Upravit objednávku</Modal.Title>
</Modal.Header>
<Form onSubmit={handleEditModalSubmit}>
<Modal.Body>
<Form.Group className="mb-3">
<Form.Label>Poznámka</Form.Label>
<Form.Control
name="note"
value={formData.note || ""}
onChange={e => setFormData(f => ({ ...f, note: e.target.value }))}
/>
</Form.Group>
{/* Add more editable fields as needed */}
{error && <Alert variant="danger">{error}</Alert>}
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={() => setShowModal(false)}>Zrušit</Button>
<Button type="submit" variant="primary" disabled={submitting}>Uložit změny</Button>
</Modal.Footer>
</Form>
</Modal>
<Modal show={showModal && modalType === "view-ticket"} onHide={() => setShowModal(false)} centered>
<Modal.Header closeButton>
<Modal.Title>Detail ticketu</Modal.Title>
</Modal.Header>
<Modal.Body>
{selectedRecord && (
<>
<p><strong>ID:</strong> {selectedRecord.id}</p>
<p><strong>Název:</strong> {selectedRecord.title}</p>
<p><strong>Vytvořeno:</strong> {dayjs(selectedRecord.created_at).format("DD.MM.YYYY HH:mm")}</p>
<p><strong>Stav:</strong> {selectedRecord.status}</p>
<p><strong>Kategorie:</strong> {selectedRecord.category}</p>
<p><strong>Popis:</strong> {selectedRecord.description}</p>
</>
)}
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={() => setShowModal(false)}>Zavřít</Button>
<Button variant="primary" onClick={() => { setShowModal(false); handleEditTicket(selectedRecord); }}>Upravit</Button>
</Modal.Footer>
</Modal>
<Modal show={showModal && modalType === "edit-ticket"} onHide={() => setShowModal(false)} centered>
<Modal.Header closeButton>
<Modal.Title>Upravit ticket</Modal.Title>
</Modal.Header>
<Form onSubmit={handleEditModalSubmit}>
<Modal.Body>
<Form.Group className="mb-3">
<Form.Label>Popis</Form.Label>
<Form.Control
name="description"
value={formData.description || ""}
onChange={e => setFormData(f => ({ ...f, description: e.target.value }))}
/>
</Form.Group>
{/* Add more editable fields as needed */}
{error && <Alert variant="danger">{error}</Alert>}
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={() => setShowModal(false)}>Zrušit</Button>
<Button type="submit" variant="primary" disabled={submitting}>Uložit změny</Button>
</Modal.Footer>
</Form>
</Modal>
</Container>
);
}
export default Home;

View File

@@ -0,0 +1,41 @@
import { Container } from "react-bootstrap";
import LoginCard from "../components/LoginCard";
import React, { useEffect, useState } from 'react';
import { getPublicAppConfig } from '../api/model/Settings';
function Login() {
const [bgUrl, setBgUrl] = useState(null);
useEffect(() => {
(async () => {
const data = await getPublicAppConfig(['background_image']);
if (data?.background_image) setBgUrl(data.background_image);
})();
}, []);
const bgStyle = bgUrl ? {
backgroundImage: `url(${bgUrl})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
minHeight: '100vh'
} : {};
return (
<Container
fluid
className="flex-grow-1 login-bg py-5"
style={bgStyle}
>
<div className="d-flex flex-column justify-content-center h-100">
<LoginCard />
</div>
<div className="m-auto">
<h2 className="text-center my-5 text-white fw-semibold">eTržnice</h2>
</div>
</Container>
);
}
export default Login;

View File

@@ -0,0 +1,45 @@
import { useParams } from "react-router-dom";
import { Container, Alert } from "react-bootstrap";
import ResetPasswordRequest from "../components/reset-password/Request";
import CreateNewPassword from "../components/reset-password/Create";
//vytáhne z URL parametry uidb64 a token
/**
* @typedef {Object} Params
* @property {string=} uidb64
* @property {string=} token
*/
function ResetPasswordPage() {
const { uidb64, token } = useParams();
const hasBothParams = uidb64 && token;
const hasOnlyOneParam = (uidb64 && !token) || (!uidb64 && token);
let content;
if (hasBothParams) {
content = <CreateNewPassword />;
} else if (hasOnlyOneParam) {
content = (
<Alert variant="danger" className="text-center">
Neplatný odkaz pro resetování hesla.
</Alert>
);
} else {
content = <ResetPasswordRequest />;
}
return (
<Container fluid className="flex-grow-1 login-bg py-5">
<div className="d-flex flex-column justify-content-center h-100">
{content}
</div>
<div className="m-auto">
<h2 className="text-center my-5 text-white fw-semibold">eTržnice</h2>
</div>
</Container>
);
}
export default ResetPasswordPage;

View File

@@ -0,0 +1,84 @@
import React, { useEffect, useState } from "react";
import axios from "axios";
import QRCode from "react-qr-code"; // use this instead of qrcode.react for Vite
import { Container, Card, Row, Col, Table } from 'react-bootstrap';
// import apiOrder from "../api/model/order"
import apiOrders from '../api/model/order'; // adjust the path if needed
export default function PaymentPage({ orderId }) {
const [order, setOrder] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
const fetchOrder = async () => {
try {
const data = await apiOrders.getOrderById(orderId); // use your imported function
console.log("Order loaded:", data);
setOrder(data);
} catch (err) {
console.error("Error fetching order:", err);
setError("Nepodařilo se načíst objednávku.");
}
};
if (orderId) {
fetchOrder();
}
}, [orderId]);
if (error) return <p>{error}</p>;
if (!order || !order.user || !order.reservation) return <p>Načítání...</p>;
// Extract relevant data
const user = order.user;
const reservation = order.reservation;
const bankAccount = user.bank_account;
const varSymbol = user.var_symbol;
const amount = order.price_to_pay;
const currency = "CZK"; // adjust if needed
const statusMap = {
payed: "Zaplaceno",
pending: "Čeká na zaplacení",
cancelled: "Stornováno"
};
const qrString = `SPD*1.0*ACC:${bankAccount}*AM:${amount}*CC:${currency}*X-VS:${varSymbol}`;
return (
<Container className="d-flex justify-content-center align-items-center mt-5">
<Card style={{ maxWidth: '800px', width: '100%' }} className="shadow">
<Card.Body>
<Card.Title className="text-center mb-4">ZAPLAŤTE OBJEDNÁVKU</Card.Title>
<Row>
{/* LEFT - Order Info */}
<Col md={6}>
<Table bordered hover>
<tbody>
<tr><td><strong>Platba:</strong></td><td>Bankovní převod</td></tr>
<tr><td><strong>Platce:</strong></td><td>{user.first_name} {user.last_name}</td></tr>
<tr><td><strong>Účet:</strong></td><td>{bankAccount}</td></tr>
<tr><td><strong>Var. symbol:</strong></td><td>{varSymbol}</td></tr>
<tr><td><strong>Částka:</strong></td><td>{amount} CZK</td></tr>
<tr><td><strong>Číslo objednávky:</strong></td><td>{order?.id}</td></tr>
<tr><td><strong>Status:</strong></td><td>{statusMap[order?.status] || "Neznámý"}</td></tr>
</tbody>
</Table>
</Col>
{/* RIGHT - QR Code */}
<Col md={6} className="d-flex flex-column align-items-center justify-content-center">
<h5>QR Platba</h5>
<QRCode value={qrString} size={200} />
</Col>
</Row>
</Card.Body>
</Card>
</Container>
);
}

View File

@@ -0,0 +1,21 @@
import {
Container,
Nav,
Navbar,
NavDropdown,
Form,
Button,
} from "react-bootstrap";
import ReservationWizard from "../components/reservation/ReservationWizard"
function ReservationCart() {
return (
<Container className="mt-5">
<h2>Rezervace</h2>
<ReservationWizard />
</Container>
);
}
export default ReservationCart;

View File

@@ -0,0 +1,109 @@
// SelectReservation.jsx
// This page displays a reservation system with a dynamic grid and a list of reservations.
import DynamicGrid, { DEFAULT_CONFIG } from "../components/DynamicGrid";
import React, { useState, useEffect } from "react";
import { Container, Row, Col, Card, ListGroup } from "react-bootstrap";
// Reservation component
// This component manages the state of reservations and provides functionality to export and clear them.
function CreateReservation() {
const gridConfig = DEFAULT_CONFIG;
const storageKey = `reservationData_${gridConfig.rows}x${gridConfig.cols}`;
const [reservations, setReservations] = useState(() => {
const saved = localStorage.getItem(storageKey);
return saved ? JSON.parse(saved) : [];
});
const [selectedIndex, setSelectedIndex] = useState(null);
useEffect(() => {
localStorage.setItem(storageKey, JSON.stringify(reservations));
}, [reservations, storageKey]);
// Function to export reservations as a JSON file
// This function creates a JSON file from the reservations state and triggers a download.
const getReservations = () => {
const dataStr = JSON.stringify(reservations, null, 2);
const blob = new Blob([dataStr], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "reservations.json";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
};
// Function to clear all reservations
// This function removes all reservations from the state and local storage.
const clearAll = () => {
localStorage.removeItem(storageKey);
setReservations([]);
setSelectedIndex(null);
};
return (
<Container>
<Row>
<Col sm={6} md={8} className="d-flex">
<DynamicGrid
config={gridConfig}
reservations={reservations}
onReservationsChange={setReservations}
selectedIndex={selectedIndex}
onSelectedIndexChange={setSelectedIndex}
static={true} // Set to true for static grid
/>
</Col>
<Col sm={6} md={4}>
<Card>
<Card.Header className="d-flex justify-content-between align-items-center">
<h5 className="mb-0">Seznam rezervací</h5>
<span className="badge bg-primary">{reservations.length}</span>
</Card.Header>
<ListGroup className="list-group-flush">
{reservations.map((res, i) => (
<ListGroup.Item
key={i}
action
active={i === selectedIndex}
onClick={() => setSelectedIndex(i)}
>
<div className="d-flex justify-content-between align-items-center">
<div>
<strong>{i + 1}.</strong> {res.name}
</div>
<span className="badge bg-secondary">
{res.w}×{res.h}
</span>
</div>
<div className="text-muted mt-1">
[{res.x},{res.y}] [{res.x + res.w - 1},{res.y + res.h - 1}
]
</div>
</ListGroup.Item>
))}
</ListGroup>
</Card>
</Col>
</Row>
<div className="mt-3">
<button onClick={getReservations} className="btn btn-primary me-2">
Export Reservations
</button>
<button onClick={clearAll} className="btn btn-danger">
Clear All
</button>
</div>
<div className="mt-3">
<pre>{JSON.stringify(reservations, null, 2)}</pre>
</div>
</Container>
);
}
export default CreateReservation;

View File

@@ -0,0 +1,29 @@
import SettingsComponent from "../components/Settings";
import { Container, Row, Col } from 'react-bootstrap';
import Sidebar from '../components/Sidebar';
// Page wrapper for site settings (admin)
function SettingsPage(){
return (
<Container
fluid
className="p-0 d-flex flex-column"
style={{ overflowX: 'hidden', height: '100vh' }}
>
<Row className="mx-0 flex-grow-1">
<Col xs={2} className="px-0 bg-light" style={{ minWidth: 0 }}>
<Sidebar />
</Col>
<Col
xs={10}
className="px-3 px-md-4 py-3 bg-white d-flex flex-column"
style={{ minWidth: 0, overflowY: 'auto' }}
>
<SettingsComponent />
</Col>
</Row>
</Container>
);
}
export default SettingsPage;

View File

@@ -0,0 +1,16 @@
import Button from "react-bootstrap/Button";
import React from "react";
function Test(){
return(
<div>
<Button variant="danger" href="/clerk/create/reservation" >Clerk</Button>
<Button variant="success" href="/seller/reservation" >Seller</Button>
<Button variant="warning" href="/components" >Components</Button>
</div>
)
}
export default Test;

View File

@@ -0,0 +1,15 @@
import Button from "react-bootstrap/Button";
import React from "react";
import TicketForm from "../components/forms/ticket"
function Ticket(){
return(
<div>
<TicketForm />
</div>
)
}
export default Ticket;

View File

View File

View File

View File

@@ -0,0 +1,421 @@
import React, { useEffect, useMemo, useState } from "react";
import Table from "../../components/Table";
import Sidebar from "../../components/Sidebar";
import {
Container,
Row,
Col,
Button as BootstrapButton,
Modal,
Form,
Alert,
} from "react-bootstrap";
import {
ActionIcon,
Group,
TextInput,
Text,
Stack,
Button,
Badge
} from "@mantine/core";
import { IconSearch, IconX, IconEye, IconEdit, IconTrash, IconPlus, IconBox } from "@tabler/icons-react";
import apiBin from "../../api/model/bin";
import { useNavigate } from "react-router-dom";
function BinManager() {
const [bins, setBins] = useState([]);
const [fetching, setFetching] = useState(true);
const [query, setQuery] = useState("");
const [selectedStatus, setSelectedStatus] = useState([]);
const [selectedBin, setSelectedBin] = useState(null);
const [showModal, setShowModal] = useState(false);
const [modalType, setModalType] = useState("view");
const [formState, setFormState] = useState({
name: "",
description: "",
status: "",
location: "",
capacity: "",
});
const navigate = useNavigate();
// Status options for filter (adjust as needed)
const statusOptions = [
{ value: "active", label: "Aktivní" },
{ value: "archived", label: "Archivováno" },
{ value: "draft", label: "Koncept" },
{ value: "disabled", label: "Neaktivní" },
];
// Fetch bins
const fetchBins = async () => {
setFetching(true);
try {
const params = { search: query };
const data = await apiBin.getBins(params);
setBins(data);
} finally {
setFetching(false);
}
};
useEffect(() => {
fetchBins();
}, [query]);
// When editing, fill formState
useEffect(() => {
if (modalType === "edit" && selectedBin) {
setFormState({
name: selectedBin.name || "",
description: selectedBin.description || "",
status: selectedBin.status || "",
location: selectedBin.location || "",
capacity: selectedBin.capacity || "",
});
}
if (modalType === "edit" && !selectedBin) {
setFormState({
name: "",
description: "",
status: "",
location: "",
capacity: "",
});
}
}, [modalType, selectedBin]);
// Handler for input changes
const handleFormChange = (e) => {
const { name, value } = e.target;
setFormState((old) => ({ ...old, [name]: value }));
};
// Save bin
const handleSaveBin = async (e) => {
e.preventDefault();
try {
if (modalType === "edit" && selectedBin) {
await apiBin.updateBin(selectedBin.id, formState);
} else {
await apiBin.createBin(formState);
}
setShowModal(false);
fetchBins();
} catch (err) {
// handle error
}
};
const handleShowBin = (bin) => {
setSelectedBin(bin);
setModalType('view');
setShowModal(true);
};
const handleEditBin = (bin) => {
setSelectedBin(bin);
setFormState({
name: bin.name || "",
description: bin.description || "",
status: bin.status || "",
location: bin.location || "",
capacity: bin.capacity || "",
});
setModalType('edit');
setShowModal(true);
};
const handleDeleteBin = async (bin) => {
if (window.confirm(`Opravdu smazat koš: ${bin.name}?`)) {
await apiBin.deleteBin(bin.id);
fetchBins();
}
};
const handleConfirmDelete = async () => {
if (selectedBin) {
await apiBin.deleteBin(selectedBin.id);
setShowModal(false);
fetchBins();
}
};
// Filtering logic
const filteredBins = useMemo(() => {
let data = Array.isArray(bins) ? bins : [];
if (query) {
const q = query.toLowerCase();
data = data.filter(
b =>
b.name?.toLowerCase().includes(q) ||
b.location?.toLowerCase().includes(q) ||
String(b.id).includes(q) ||
b.status?.toLowerCase().includes(q)
);
}
if (selectedStatus.length > 0) {
data = data.filter(b => selectedStatus.includes(b.status));
}
return data;
}, [bins, query, selectedStatus]);
// Table columns
const columns = [
{ accessor: "id", title: "#", sortable: true, width: "4%" },
{
accessor: "name",
title: "Název",
sortable: true,
width: "20%",
filter: (
<TextInput
label="Hledat název"
placeholder="Např. Koš 1, Sklad A..."
leftSection={<IconSearch size={16} />}
rightSection={
<ActionIcon size="sm" variant="transparent" c="dimmed" onClick={() => setQuery("")}>
<IconX size={14} />
</ActionIcon>
}
value={query}
onChange={e => setQuery(e.currentTarget.value)}
/>
),
filtering: query !== "",
},
{
accessor: "description",
title: "Popis",
sortable: false,
width: "20%",
render: row => row.description || <Text c="dimmed" fs="italic"></Text>,
},
{
accessor: "location",
title: "Umístění",
sortable: true,
width: "15%",
render: row => row.location || <Text c="dimmed" fs="italic"></Text>,
},
{
accessor: "capacity",
title: "Kapacita",
sortable: true,
width: "10%",
render: row => row.capacity ? `${row.capacity}` : "—",
},
{
accessor: "status",
title: "Stav",
sortable: true,
width: "10%",
render: row => {
const statusLabel = statusOptions.find(opt => opt.value === row.status)?.label || row.status;
return <Badge color={row.status === "active" ? "green" : row.status === "archived" ? "gray" : "yellow"}>{statusLabel}</Badge>;
},
filter: (() => {
const toggle = (val) => {
setSelectedStatus(prev => prev.includes(val) ? prev.filter(v => v !== val) : [...prev, val]);
};
return (
<Stack gap={4} style={{ minWidth:160 }}>
<Text fw={500} size="xs" c="dimmed">Filtrovat stav</Text>
<Group gap={6} wrap="wrap">
{statusOptions.map(opt => {
const active = selectedStatus.includes(opt.value);
const color = opt.value === 'active' ? 'green' : opt.value === 'archived' ? 'gray' : 'yellow';
return (
<Badge
key={opt.value}
color={color}
variant={active ? 'filled' : 'outline'}
style={{ cursor:'pointer' }}
onClick={() => toggle(opt.value)}
aria-pressed={active}
role='button'
>
{opt.label}
</Badge>
);
})}
</Group>
{selectedStatus.length > 0 && (
<Button size="xs" variant="light" onClick={() => setSelectedStatus([])}>Reset</Button>
)}
</Stack>
);
})(),
filtering: selectedStatus.length > 0,
},
{
accessor: "actions",
title: "Akce",
width: "10%",
render: (bin) => (
<Group gap={4}>
<ActionIcon size="sm" variant="subtle" color="green" onClick={() => handleShowBin(bin)}>
<IconEye size={16} />
</ActionIcon>
<ActionIcon size="sm" variant="subtle" color="blue" onClick={() => handleEditBin(bin)}>
<IconEdit size={16} />
</ActionIcon>
<ActionIcon size="sm" variant="subtle" color="red" onClick={() => handleDeleteBin(bin)}>
<IconTrash size={16} />
</ActionIcon>
</Group>
),
},
];
// Modal content
const renderModalContent = () => {
if (modalType === "view" && selectedBin) {
return (
<Stack>
<Text><strong>ID:</strong> {selectedBin.id}</Text>
<Text><strong>Název:</strong> {selectedBin.name}</Text>
<Text><strong>Popis:</strong> {selectedBin.description || "—"}</Text>
<Text><strong>Umístění:</strong> {selectedBin.location || "—"}</Text>
<Text><strong>Kapacita:</strong> {selectedBin.capacity || "—"}</Text>
<Text><strong>Stav:</strong> {selectedBin.status || "—"}</Text>
<Group mt="md">
<Button variant="outline" onClick={() => setShowModal(false)}>Zavřít</Button>
<Button onClick={() => handleEditBin(selectedBin)}>Upravit</Button>
</Group>
</Stack>
);
}
if (modalType === "edit") {
return (
<form onSubmit={handleSaveBin}>
<Stack spacing="sm">
<TextInput
label="Název"
name="name"
value={formState.name}
onChange={handleFormChange}
required
/>
<TextInput
label="Popis"
name="description"
value={formState.description}
onChange={handleFormChange}
/>
<TextInput
label="Umístění"
name="location"
value={formState.location}
onChange={handleFormChange}
/>
<TextInput
label="Kapacita"
name="capacity"
type="number"
value={formState.capacity}
onChange={handleFormChange}
/>
<select
name="status"
value={formState.status}
onChange={handleFormChange}
required
style={{ padding: '8px', borderRadius: '4px' }}
>
<option value="">Vyber stav</option>
{statusOptions.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
<Group position="right" mt="md">
<Button variant="outline" onClick={() => setShowModal(false)}>Zrušit</Button>
<Button type="submit" color="blue">Uložit</Button>
</Group>
</Stack>
</form>
);
}
if (modalType === "delete" && selectedBin) {
return (
<Stack>
<Text>Opravdu chcete smazat koš "{selectedBin.name}"?</Text>
<Group mt="md">
<Button variant="outline" onClick={() => setShowModal(false)}>Zrušit</Button>
<Button color="red" onClick={handleConfirmDelete}>Smazat</Button>
</Group>
</Stack>
);
}
return <Text>Žádný obsah</Text>;
};
const getModalTitle = () => {
if (!selectedBin && modalType !== "edit") return "Detail koše";
switch (modalType) {
case "view":
return `Detail: ${selectedBin?.name}`;
case "edit":
return selectedBin ? `Upravit: ${selectedBin.name}` : "Přidat koš";
case "delete":
return `Smazat koš`;
default:
return "Detail koše";
}
};
return (
<Container fluid className="p-0 d-flex flex-column" style={{ overflowX: "hidden", height: "100vh" }}>
<Row className="mx-0 flex-grow-1">
<Col xs={2} className="px-0 bg-light" style={{ minWidth: 0 }}>
<Sidebar />
</Col>
<Col xs={10} className="px-0 bg-white d-flex flex-column" style={{ minWidth: 0 }}>
<Group justify="space-between" align="center" px="md" py="sm">
<h1>
<IconBox size={28} style={{ marginRight: 8, marginBottom: 0 }} />
Koše
</h1>
<Button leftSection={<IconPlus size={16} />} onClick={() => { setSelectedBin(null); setModalType("edit"); setShowModal(true); }}>
Přidat koš
</Button>
</Group>
<Table
data={filteredBins}
columns={columns}
fetching={fetching}
withTableBorder
borderRadius="md"
highlightOnHover
verticalAlign="center"
titlePadding="4px 8px"
/>
<Modal
show={showModal}
onHide={() => setShowModal(false)}
title={modalType === "edit" && !selectedBin ? "Přidat koš" : getModalTitle()}
size="lg"
centered
>
<Modal.Body>
{renderModalContent()}
</Modal.Body>
<Modal.Footer>
<BootstrapButton variant="secondary" onClick={() => setShowModal(false)}>Zavřít</BootstrapButton>
{modalType === "view" && (
<BootstrapButton variant="primary" onClick={() => { setShowModal(false); handleEditBin(selectedBin); }}>Upravit</BootstrapButton>
)}
</Modal.Footer>
</Modal>
</Col>
</Row>
</Container>
);
}
export default BinManager;

View File

@@ -0,0 +1,621 @@
import React, { useEffect, useMemo, useState } from "react";
import Table from "../../components/Table";
import Sidebar from "../../components/Sidebar";
import {
Container,
Row,
Col,
Button as BootstrapButton,
Modal,
Form,
Alert,
} from "react-bootstrap";
import {
ActionIcon,
Group,
TextInput,
Text,
Stack,
Button,
Badge
} from "@mantine/core";
import { IconSearch, IconX, IconEye, IconEdit, IconTrash, IconPlus, IconMap, IconCalendarEvent } from "@tabler/icons-react";
import apiEvents from "../../api/model/event";
import dayjs from "dayjs";
import "dayjs/locale/cs";
import { useNavigate } from "react-router-dom";
function Events() {
const [events, setEvents] = useState([]);
const [fetching, setFetching] = useState(true);
const [query, setQuery] = useState("");
const [selectedStatus, setSelectedStatus] = useState([]);
const [selectedEvent, setSelectedEvent] = useState(null);
const [showModal, setShowModal] = useState(false);
const [modalType, setModalType] = useState("view");
const [formState, setFormState] = useState({
name: "",
description: "",
start: "",
end: "",
price_per_m2: "",
image: null,
square_id: "",
});
const [squares, setSquares] = useState([]);
const [squareSearch, setSquareSearch] = useState("");
const [selectedSquareIds, setSelectedSquareIds] = useState([]);
const [startDateRange, setStartDateRange] = useState([null, null]);
const [endDateRange, setEndDateRange] = useState([null, null]);
const navigate = useNavigate();
// Status options for filter (adjust as needed)
const statusOptions = [
{ value: "active", label: "Aktivní" },
{ value: "archived", label: "Archivováno" },
{ value: "draft", label: "Koncept" },
{ value: "cancelled", label: "Zrušeno" },
];
// Fetch squares for dropdown
useEffect(() => {
const fetchSquares = async () => {
try {
const data = await import("../../api/model/square").then(mod => mod.default.getSquares());
setSquares(data);
} catch (err) {
// ignore
}
};
fetchSquares();
}, []);
// Když se vybere event pro editaci, naplníme formState
useEffect(() => {
if (modalType === "edit" && selectedEvent) {
setFormState({
name: selectedEvent.name || "",
description: selectedEvent.description || "",
start: selectedEvent.start || "", // YYYY-MM-DD
end: selectedEvent.end || "",
price_per_m2: selectedEvent.price_per_m2 || "",
image: null,
square_id: selectedEvent.square_id || selectedEvent.square?.id || "",
});
}
if (modalType === "edit" && !selectedEvent) {
// Přidávání nového eventu: vyčistit form
setFormState({
name: "",
description: "",
start: "",
end: "",
price_per_m2: "",
image: null,
square_id: "",
});
}
}, [modalType, selectedEvent]);
// Handler pro změnu inputů
const handleFormChange = (e) => {
const { name, value, files } = e.target;
if (name === "image") {
setFormState((old) => ({ ...old, image: files[0] || null }));
} else {
setFormState((old) => ({ ...old, [name]: value }));
}
};
// Odeslání formuláře
const handleSaveEvent = async (e) => {
e.preventDefault();
try {
const formData = new FormData();
formData.append("name", formState.name);
formData.append("description", formState.description);
formData.append("start", formState.start); // YYYY-MM-DD
formData.append("end", formState.end); // YYYY-MM-DD
formData.append("price_per_m2", formState.price_per_m2);
formData.append("square_id", formState.square_id);
if (formState.image instanceof File) {
formData.append("image", formState.image);
}
if (modalType === "edit" && selectedEvent) {
await apiEvents.updateEvent(selectedEvent.id, formData);
} else {
await apiEvents.createEvent(formData);
}
setShowModal(false);
fetchEvents();
} catch (err) {
console.error("Chyba při ukládání akce:", err);
}
};
const fetchEvents = async () => {
setFetching(true);
try {
const params = { search: query };
const data = await apiEvents.getEvents(params);
setEvents(data);
} finally {
setFetching(false);
}
};
useEffect(() => {
fetchEvents();
}, [query]);
const handleShowEvent = (event) => {
setSelectedEvent(event);
setModalType('view');
setShowModal(true);
};
const handleEditEvent = (event) => {
setSelectedEvent(event);
setFormState({
name: event.name || "",
description: event.description || "",
start: event.start ? event.start.slice(0, 16) : "",
end: event.end ? event.end.slice(0, 16) : "",
price_per_m2: event.price_per_m2 || "",
image: null,
square_id: event.square_id || event.square?.id || "",
});
setModalType('edit');
setShowModal(true);
// Optionally clear error state if you add error handling
};
const handleDeleteEvent = async (event) => {
if (window.confirm(`Opravdu smazat akci: ${event.name}?`)) {
await apiEvents.deleteEvent(event.id);
fetchEvents();
}
};
const handleConfirmDelete = async () => {
if (selectedEvent) {
await apiEvents.deleteEvent(selectedEvent.id);
setShowModal(false);
fetchEvents();
}
};
const handleRedirectToMap = (event) => {
navigate(`/manage/events/map/${event.id}`);
};
// Set dayjs locale to Czech
useEffect(() => {
dayjs.locale("cs");
}, []);
// Upravený renderModalContent s formulářem
const renderModalContent = () => {
if (modalType === "view" && selectedEvent) {
return (
<Stack>
<Text><strong>ID:</strong> {selectedEvent.id}</Text>
<Text><strong>Název:</strong> {selectedEvent.name}</Text>
<Text><strong>Popis:</strong> {selectedEvent.description || "—"}</Text>
<Text><strong>Náměstí:</strong> {selectedEvent.square?.name || "Neznámé"}</Text>
<Text><strong>Začátek:</strong> {new Date(selectedEvent.start).toLocaleString()}</Text>
<Text><strong>Konec:</strong> {new Date(selectedEvent.end).toLocaleString()}</Text>
<Group mt="md">
<Button variant="outline" onClick={() => setShowModal(false)}>Zavřít</Button>
<Button onClick={() => handleEditEvent(selectedEvent)}>Upravit</Button>
</Group>
</Stack>
);
}
if (modalType === "edit") {
return (
<form onSubmit={handleSaveEvent}>
<Stack spacing="sm">
<TextInput
label="Název"
name="name"
value={formState.name}
onChange={handleFormChange}
required
/>
<TextInput
label="Popis"
name="description"
value={formState.description}
onChange={handleFormChange}
/>
<TextInput
label="Začátek"
type="date"
name="start"
value={formState.start}
onChange={handleFormChange}
required
/>
<TextInput
label="Konec"
type="date"
name="end"
value={formState.end}
onChange={handleFormChange}
required
/>
<TextInput
label="Cena za m²"
name="price_per_m2"
value={formState.price_per_m2}
onChange={handleFormChange}
placeholder="např. 1000"
/>
{/* Square search and select */}
<TextInput
label="Hledat náměstí"
placeholder="Zadej název nebo město"
value={squareSearch}
onChange={e => setSquareSearch(e.target.value)}
/>
<select
name="square_id"
value={formState.square_id}
onChange={handleFormChange}
required
style={{ padding: '8px', borderRadius: '4px' }}
>
<option value="">Vyber náměstí</option>
{squares.filter(sq =>
sq.name.toLowerCase().includes(squareSearch.toLowerCase()) ||
sq.city.toLowerCase().includes(squareSearch.toLowerCase())
).map(sq => (
<option key={sq.id} value={sq.id}>{sq.name} ({sq.city})</option>
))}
</select>
<input
type="file"
name="image"
accept="image/*"
onChange={handleFormChange}
style={{ marginTop: 10 }}
/>
<Group position="right" mt="md">
<Button variant="outline" onClick={() => setShowModal(false)}>Zrušit</Button>
<Button type="submit" color="blue">Uložit</Button>
</Group>
</Stack>
</form>
);
}
if (modalType === "delete" && selectedEvent) {
return (
<Stack>
<Text>Opravdu chcete smazat akci "{selectedEvent.name}"?</Text>
<Group mt="md">
<Button variant="outline" onClick={() => setShowModal(false)}>Zrušit</Button>
<Button color="red" onClick={handleConfirmDelete}>Smazat</Button>
</Group>
</Stack>
);
}
return <Text>Žádný obsah</Text>;
};
// getModalTitle můžeš použít stejný, např:
const getModalTitle = () => {
if (!selectedEvent && modalType !== "edit") return "Detail akce";
switch (modalType) {
case "view":
return `Detail: ${selectedEvent?.name}`;
case "edit":
return selectedEvent ? `Upravit: ${selectedEvent.name}` : "Přidat akci";
case "delete":
return `Smazat akci`;
default:
return "Detail akce";
}
};
// Squares for filter
const squareOptions = useMemo(() => {
if (!Array.isArray(squares)) return [];
return squares.map(sq => ({
value: String(sq.id),
label: `${sq.name} (${sq.city})`
}));
}, [squares]);
// Filtering logic update
const filteredEvents = useMemo(() => {
let data = Array.isArray(events) ? events : [];
if (query) {
const q = query.toLowerCase();
data = data.filter(
e =>
e.name?.toLowerCase().includes(q) ||
e.location?.toLowerCase().includes(q) ||
String(e.id).includes(q) ||
e.status?.toLowerCase().includes(q)
);
}
if (selectedStatus.length > 0) {
data = data.filter(e => selectedStatus.includes(e.status));
}
if (selectedSquareIds.length > 0) {
data = data.filter(e =>
selectedSquareIds.includes(String(e.square_id || e.square?.id))
);
}
// Začátek (start) date filter: show only events where start date (YYYY-MM-DD) matches the filter
if (startDateRange[0]) {
data = data.filter(e =>
e.start && dayjs(e.start).format("YYYY-MM-DD") === startDateRange[0]
);
}
// Konec (end) date filter: show only events where end date (YYYY-MM-DD) matches the filter
if (endDateRange[0]) {
data = data.filter(e =>
e.end && dayjs(e.end).format("YYYY-MM-DD") === endDateRange[0]
);
}
return data;
}, [events, query, selectedStatus, selectedSquareIds, startDateRange, endDateRange]);
// Show all fields in the table, based on EventSerializer in backend/booking/serializers.py
const columns = [
{ accessor: "id", title: "#", sortable: true, width: "4%" },
{
accessor: "name",
title: "Název",
sortable: true,
width: "15%",
filter: (
<TextInput
label="Hledat název"
placeholder="Např. Trh, Koncert..."
leftSection={<IconSearch size={16} />}
rightSection={
<ActionIcon size="sm" variant="transparent" c="dimmed" onClick={() => setQuery("")}>
<IconX size={14} />
</ActionIcon>
}
value={query}
onChange={e => setQuery(e.currentTarget.value)}
/>
),
filtering: query !== "",
},
{
accessor: "description",
title: "Popis",
sortable: false,
width: "10%",
render: row => row.description || <Text c="dimmed" fs="italic"></Text>,
},
{
accessor: "start",
title: "Začátek",
sortable: true,
width: "14%",
render: row => row.start ? dayjs(row.start, "YYYY-MM-DD").format("DD.MM.YYYY") : "—",
filter: (
<Group gap={4}>
<TextInput
type="date"
label="Datum"
value={startDateRange[0] || ""}
onChange={e => setStartDateRange([e.target.value || null, null])}
style={{ width: 140 }}
/>
{startDateRange[0] && (
<ActionIcon size="sm" variant="transparent" c="dimmed" onClick={() => setStartDateRange([null, null])}>
<IconX size={14} />
</ActionIcon>
)}
</Group>
),
filtering: !!startDateRange[0],
},
{
accessor: "end",
title: "Konec",
sortable: true,
width: "14%",
render: row => row.end ? dayjs(row.end, "YYYY-MM-DD").format("DD.MM.YYYY") : "—",
filter: (
<Group gap={4}>
<TextInput
type="date"
label="Datum"
value={endDateRange[0] || ""}
onChange={e => setEndDateRange([e.target.value || null, null])}
style={{ width: 140 }}
/>
{endDateRange[0] && (
<ActionIcon size="sm" variant="transparent" c="dimmed" onClick={() => setEndDateRange([null, null])}>
<IconX size={14} />
</ActionIcon>
)}
</Group>
),
filtering: !!endDateRange[0],
},
{
accessor: "price_per_m2",
title: "Cena za m²",
sortable: true,
width: "9%",
render: row => row.price_per_m2 ? `${row.price_per_m2}` : "—",
},
{
accessor: "square",
title: "Náměstí",
sortable: false,
width: "16%",
render: row => row.square?.name ? `${row.square.name}` : <Text c="dimmed" fs="italic"></Text>,
filter: (() => {
const toggle = (val) => {
setSelectedSquareIds(prev => prev.includes(val) ? prev.filter(v => v !== val) : [...prev, val]);
};
return (
<Stack gap={4} style={{ minWidth: 170 }}>
<Text fw={500} size="xs" c="dimmed">Filtrovat náměstí</Text>
<Group gap={6} wrap="wrap">
{squareOptions.map(opt => {
const active = selectedSquareIds.includes(opt.value);
return (
<Badge
key={opt.value}
color={active ? 'blue' : 'gray'}
variant={active ? 'filled' : 'outline'}
style={{ cursor:'pointer' }}
onClick={() => toggle(opt.value)}
aria-pressed={active}
role='button'
>
{opt.label}
</Badge>
);
})}
</Group>
{selectedSquareIds.length > 0 && (
<Button size="xs" variant="light" onClick={() => setSelectedSquareIds([])}>Reset</Button>
)}
</Stack>
);
})(),
filtering: selectedSquareIds.length > 0,
},
{
accessor: "image",
title: "Obrázek",
sortable: false,
width: "11%",
render: row =>
row.image ? (
<img src={row.image} alt={row.name} style={{ width: "100px", height: "auto", borderRadius: "8px" }} />
) : (
<Text c="dimmed" fs="italic">Žádný obrázek</Text>
),
},
{
accessor: "actions",
title: "Akce",
width: "5.5%",
render: (event) => (
<Container>
<Row>
<Col style={{ padding: 0, textAlign: "center", flexGrow: 0 }}>
<ActionIcon size="sm" variant="subtle" color="green" onClick={() => handleShowEvent(event)}>
<IconEye size={16} />
</ActionIcon>
</Col>
<Col style={{ padding: 0, textAlign: "center", flexGrow: 0 }}>
<ActionIcon size="sm" variant="subtle" color="blue" onClick={() => handleEditEvent(event)}>
<IconEdit size={16} />
</ActionIcon>
</Col>
</Row>
<Row>
<Col style={{ padding: 0, textAlign: "center", flexGrow: 0 }}>
<ActionIcon size="sm" variant="subtle" color="green" onClick={() => handleRedirectToMap(event)} title="Mapa">
<IconMap size={16} />
</ActionIcon>
</Col>
<Col style={{ padding: 0, textAlign: "center", flexGrow: 0 }}>
<ActionIcon size="sm" variant="subtle" color="red" onClick={() => handleDeleteEvent(event)}>
<IconTrash size={16} />
</ActionIcon>
</Col>
</Row>
</Container>
),
},
];
return (
<Container fluid className="p-0 d-flex flex-column" style={{ overflowX: "hidden", height: "100vh" }}>
<Row className="mx-0 flex-grow-1">
<Col xs={2} className="px-0 bg-light" style={{ minWidth: 0 }}>
<Sidebar />
</Col>
<Col xs={10} className="px-0 bg-white d-flex flex-column" style={{ minWidth: 0 }}>
<Group justify="space-between" align="center" px="md" py="sm">
<h1>
<IconCalendarEvent size={28} style={{ marginRight: 8, marginBottom: 0 }} />
Akce
</h1>
<Button component="a" href="/manage/events/create" leftSection={<IconPlus size={16} />}>Přidat akci</Button>
</Group>
<Table
data={filteredEvents}
columns={columns}
fetching={fetching}
withTableBorder
borderRadius="md"
highlightOnHover
verticalAlign="center"
titlePadding="4px 8px"
/>
<Modal
show={showModal}
onHide={() => setShowModal(false)}
title={modalType === "edit" && !selectedEvent ? "Přidat akci" : getModalTitle()}
size="lg"
centered
>
{modalType === "view" ? (
<Modal.Body>
{selectedEvent && (
<>
<p><strong>ID:</strong> {selectedEvent.id}</p>
<p><strong>Název:</strong> {selectedEvent.name}</p>
<p><strong>Popis:</strong> {selectedEvent.description || "—"}</p>
<p><strong>Náměstí:</strong> {selectedEvent.square?.name || "Neznámé"}</p>
<p><strong>Začátek:</strong> {selectedEvent.start ? dayjs(selectedEvent.start).format("DD.MM.YYYY HH:mm") : "—"}</p>
<p><strong>Konec:</strong> {selectedEvent.end ? dayjs(selectedEvent.end).format("DD.MM.YYYY HH:mm") : "—"}</p>
<p><strong>Cena za :</strong> {selectedEvent.price_per_m2 ? `${selectedEvent.price_per_m2}` : "—"}</p>
<p><strong>Počet míst:</strong> {Array.isArray(selectedEvent.market_slots) ? selectedEvent.market_slots.length : "—"}</p>
<p><strong>Produkty:</strong> {Array.isArray(selectedEvent.event_products) && selectedEvent.event_products.length > 0
? selectedEvent.event_products.map(p => p.name).join(", ")
: "—"}</p>
<p><strong>Obrázek:</strong> {selectedEvent.image ? <img src={selectedEvent.image} alt={selectedEvent.name} style={{ width: "100px", height: "auto", borderRadius: "8px" }} /> : "Žádný obrázek"}</p>
</>
)}
</Modal.Body>
) : (
<Modal.Body>
{renderModalContent()}
</Modal.Body>
)}
<Modal.Footer>
<BootstrapButton variant="secondary" onClick={() => setShowModal(false)}>Zavřít</BootstrapButton>
{modalType === "view" && (
<BootstrapButton variant="primary" onClick={() => { setShowModal(false); handleEditEvent(selectedEvent); }}>Upravit</BootstrapButton>
)}
</Modal.Footer>
</Modal>
</Col>
</Row>
</Container>
);
}
export default Events;

View File

@@ -0,0 +1,411 @@
import React, { useEffect, useMemo, useState } from "react";
import Table from "../../components/Table";
import Sidebar from "../../components/Sidebar";
import {
Container,
Row,
Col,
Button as BootstrapButton,
Modal,
Form,
Alert,
} from "react-bootstrap";
import {
ActionIcon,
Group,
TextInput,
Text,
Stack,
Button,
Badge
} from "@mantine/core";
import { IconSearch, IconX, IconEye, IconEdit, IconTrash, IconPlus, IconReceipt2 } from "@tabler/icons-react";
import orderAPI from "../../api/model/order";
import userAPI from "../../api/model/user";
function Orders() {
// Delete handler
const handleDeleteOrder = async (order) => {
if (window.confirm(`Opravdu smazat objednávku: ${order.id}?`)) {
await orderAPI.deleteOrder(order.id);
const data = await orderAPI.getOrders();
setOrders(data);
}
};
// Bootstrap Modal state for edit
const [showEditModal, setShowEditModal] = useState(false);
const handleEditOrder = (order) => {
setSelectedOrder(order);
setFormData({
note: order.note || "",
status: order.status || "",
price_to_pay: order.price_to_pay || "",
payed_at: order.payed_at || "",
});
setShowEditModal(true);
};
const handleEditModalSubmit = async (e) => {
e.preventDefault();
setError(null);
setSubmitting(true);
try {
// Explicitly send all formData fields, including status
console.log(formData);
await orderAPI.updateOrder(selectedOrder.id, formData);
setShowEditModal(false);
setFormData({
note: "",
status: "",
price_to_pay: "",
payed_at: "",
});
const data = await orderAPI.getOrders();
setOrders(data);
} catch (err) {
const apiErrors = err.response?.data;
if (typeof apiErrors === "object") {
const messages = Object.entries(apiErrors)
.map(([key, value]) => `${key}: ${Array.isArray(value) ? value.join(", ") : value}`)
.join("\n");
setError("Chyba při ukládání:\n" + messages);
} else {
setError("Chyba při ukládání: " + (err.message || "Neznámá chyba"));
}
} finally {
setSubmitting(false);
}
};
const [orders, setOrders] = useState([]);
const [fetching, setFetching] = useState(true);
const [query, setQuery] = useState("");
const [selectedStatus, setSelectedStatus] = useState([]);
const [selectedUsers, setSelectedUsers] = useState([]);
const [userOptions, setUserOptions] = useState([]);
const [userQuery, setUserQuery] = useState("");
// Modal state
const [showModal, setShowModal] = useState(false);
const [modalType, setModalType] = useState('view'); // 'view', 'edit'
const [selectedOrder, setSelectedOrder] = useState(null);
const [formData, setFormData] = useState({
note: "",
status: "",
price_to_pay: "",
payed_at: "",
});
const [error, setError] = useState(null);
const [submitting, setSubmitting] = useState(false);
// Fetch data from API
useEffect(() => {
const fetchData = async () => {
try {
const data = await orderAPI.getOrders();
setOrders(data);
} finally {
setFetching(false);
}
};
fetchData();
}, []);
// Fetch user options for filter
useEffect(() => {
async function fetchUsers() {
try {
const users = await userAPI.getUsers();
setUserOptions(
users.map(u => ({
value: String(u.id), // Mantine expects string values
label: `${u.first_name} ${u.last_name} (${u.email})`
}))
);
} catch (e) {
setUserOptions([]);
}
}
fetchUsers();
}, []);
// Status options for filter
const statusOptions = [
{ value: "pending", label: "Čeká na zaplacení" },
{ value: "payed", label: "Zaplaceno" },
{ value: "cancelled", label: "Stornováno" },
];
// Status colors
const statusColors = {
pending: "yellow",
payed: "green",
cancelled: "red",
};
// Filtering
const filteredOrders = useMemo(() => {
let data = Array.isArray(orders) ? orders : [];
if (query) {
const q = query.toLowerCase();
data = data.filter(
r =>
r.order_number?.toLowerCase().includes(q) ||
r.note?.toLowerCase().includes(q) ||
r.user?.email?.toLowerCase().includes(q) ||
r.user?.first_name?.toLowerCase().includes(q) ||
r.user?.last_name?.toLowerCase().includes(q) ||
r.reservation?.note?.toLowerCase().includes(q)
);
}
if (userQuery) {
const uq = userQuery.toLowerCase();
data = data.filter(r =>
r.user &&
(
r.user.email?.toLowerCase().includes(uq) ||
r.user.first_name?.toLowerCase().includes(uq) ||
r.user.last_name?.toLowerCase().includes(uq)
)
);
}
if (selectedStatus.length > 0) {
data = data.filter(r => selectedStatus.includes(r.status));
}
return data;
}, [orders, query, selectedStatus, userQuery]);
// Handle form field changes
const handleChange = (e) => {
const { name, value } = e.target;
setFormData((old) => ({ ...old, [name]: value }));
};
// Handlers for modal actions
const handleShowOrder = (order) => {
setSelectedOrder(order);
setModalType('view');
setShowModal(true);
};
const columns = [
{ accessor: "id", title: "ID objednávky", sortable: true, width: "14%" },
{
accessor: "user",
title: "Uživatel",
width: "16%",
filter: (
<TextInput
label="Hledat uživatele"
placeholder="Jméno, příjmení, email"
leftSection={<IconSearch size={16} />}
rightSection={
<ActionIcon size="sm" variant="transparent" c="dimmed" onClick={() => setUserQuery("")}>
<IconX size={14} />
</ActionIcon>
}
value={userQuery}
onChange={e => setUserQuery(e.currentTarget.value)}
/>
),
filtering: userQuery !== "",
render: (row) => row.user ? `${row.user.first_name} ${row.user.last_name} (${row.user.email})` : "—",
},
{
accessor: "reservation",
title: "Rezervace",
width: "16%",
render: (row) => row.reservation ? `ID: ${row.reservation.id}` : "—",
},
{ accessor: "created_at", title: "Vytvořeno", sortable: true, width: "12%" },
{
accessor: "status",
title: "Stav",
width: "12%",
filter: (() => {
const toggle = (value) => {
setSelectedStatus(prev => prev.includes(value) ? prev.filter(v => v !== value) : [...prev, value]);
};
return (
<Stack gap={4} style={{ minWidth: 160 }}>
<Text fw={500} size="xs" c="dimmed">Filtrovat stav</Text>
<Group gap={6} wrap="wrap">
{statusOptions.map(opt => {
const active = selectedStatus.includes(opt.value);
const color = statusColors[opt.value] || 'gray';
return (
<Badge
key={opt.value}
color={color}
variant={active ? 'light' : 'outline'}
style={{ cursor: 'pointer', userSelect: 'none' }}
onClick={() => toggle(opt.value)}
aria-pressed={active}
role="button"
>
{opt.label}
</Badge>
);
})}
</Group>
{selectedStatus.length > 0 && (
<Button size="xs" variant="light" onClick={() => setSelectedStatus([])}>Reset</Button>
)}
</Stack>
);
})(),
filtering: selectedStatus.length > 0,
render: (row) => {
const statusObj = statusOptions.find(opt => opt.value === row.status);
const color = statusColors[row.status] || "gray";
return (
<Badge color={color} variant="light">
{statusObj ? statusObj.label : row.status}
</Badge>
);
},
},
{ accessor: "price_to_pay", title: "Cena", width: "8%" },
{ accessor: "payed_at", title: "Zaplaceno dne", width: "12%" },
{ accessor: "note", title: "Poznámka", width: "12%" },
{
accessor: "actions",
title: "Akce",
width: "8%",
render: (order) => (
<Group gap={4} wrap="nowrap">
<ActionIcon size="sm" variant="subtle" color="green" onClick={() => handleShowOrder(order)}>
<IconEye size={16} />
</ActionIcon>
<ActionIcon size="sm" variant="subtle" color="blue" onClick={() => handleEditOrder(order)}>
<IconEdit size={16} />
</ActionIcon>
<ActionIcon size="sm" variant="subtle" color="red" onClick={() => handleDeleteOrder(order)}>
<IconTrash size={16} />
</ActionIcon>
</Group>
),
},
];
// Modal content for view/edit
const renderModalContent = () => {
return <Text>Žádný obsah</Text>;
};
return (
<Container fluid className="p-0 d-flex flex-column" style={{ overflowX: "hidden", height: "100vh" }}>
<Row className="mx-0 flex-grow-1">
<Col xs={2} className="px-0 bg-light" style={{ minWidth: 0 }}>
<Sidebar />
</Col>
<Col xs={10} className="px-0 bg-white d-flex flex-column" style={{ minWidth: 0 }}>
<Group justify="space-between" align="center" px="md" py="sm">
<h1>
<IconReceipt2 size={30} style={{ marginRight: 10, marginTop: -4 }} />
Objednávky
</h1>
{/* You can add a button for creating new orders if needed */}
</Group>
<Table
data={filteredOrders}
columns={columns}
fetching={fetching}
withTableBorder
borderRadius="md"
highlightOnHover
verticalAlign="center"
titlePadding="4px 8px"
/>
{/* Mantine modal for add only (not used for orders) */}
<Modal
opened={showModal && modalType === 'add'}
onClose={() => setShowModal(false)}
title={'Přidat objednávku'}
size="lg"
centered
>
{renderModalContent()}
</Modal>
{/* Bootstrap Modal for view */}
<Modal show={showModal && modalType === 'view'} onHide={() => setShowModal(false)} centered>
<Modal.Header closeButton>
<Modal.Title>Detail objednávky</Modal.Title>
</Modal.Header>
<Modal.Body>
{selectedOrder && (
<>
<p><strong>ID objednávky:</strong> {selectedOrder.id}</p>
<p><strong>Uživatel:</strong> {selectedOrder.user ? `${selectedOrder.user.first_name} ${selectedOrder.user.last_name} (${selectedOrder.user.email})` : "—"}</p>
<p><strong>Rezervace:</strong> {selectedOrder.reservation ? `ID: ${selectedOrder.reservation.id}` : "—"}</p>
<p><strong>Vytvořeno:</strong> {selectedOrder.created_at}</p>
<p>
<strong>Stav:</strong>{" "}
<Badge color={statusColors[selectedOrder.status] || "gray"} variant="light">
{statusOptions.find(opt => opt.value === selectedOrder.status)?.label || selectedOrder.status}
</Badge>
</p>
<p><strong>Cena:</strong> {selectedOrder.price_to_pay}</p>
<p><strong>Zaplaceno dne:</strong> {selectedOrder.payed_at || "—"}</p>
<p><strong>Poznámka:</strong> {selectedOrder.note || "—"}</p>
</>
)}
</Modal.Body>
<Modal.Footer>
<BootstrapButton variant="secondary" onClick={() => setShowModal(false)}>Zavřít</BootstrapButton>
<BootstrapButton variant="primary" onClick={() => { setShowModal(false); handleEditOrder(selectedOrder); }}>Upravit</BootstrapButton>
</Modal.Footer>
</Modal>
{/* Bootstrap Modal for edit */}
<Modal show={showEditModal} onHide={() => setShowEditModal(false)} centered>
<Modal.Header closeButton>
<Modal.Title>Upravit objednávku</Modal.Title>
</Modal.Header>
<Form onSubmit={handleEditModalSubmit}>
<Modal.Body>
<Form.Group className="mb-3">
<Form.Label>Poznámka</Form.Label>
<Form.Control name="note" value={formData.note} onChange={handleChange} />
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Stav</Form.Label>
<Form.Select
name="status"
value={formData.status}
onChange={handleChange}
>
<option value="">Vyberte stav</option>
{statusOptions.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</Form.Select>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Cena</Form.Label>
<Form.Control name="price_to_pay" value={formData.price_to_pay} onChange={handleChange} type="number" min="0" />
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Zaplaceno dne</Form.Label>
<Form.Control name="payed_at" value={formData.payed_at} onChange={handleChange} type="datetime-local" />
</Form.Group>
{error && <Alert variant="danger">{error}</Alert>}
</Modal.Body>
<Modal.Footer>
<BootstrapButton variant="secondary" onClick={() => setShowEditModal(false)}>Zrušit</BootstrapButton>
<BootstrapButton type="submit" variant="primary" disabled={submitting}>Uložit změny</BootstrapButton>
</Modal.Footer>
</Form>
</Modal>
</Col>
</Row>
</Container>
);
}
export default Orders;

View File

@@ -0,0 +1,251 @@
import React, { useEffect, useMemo, useState } from 'react';
import Table from '../../components/Table';
import Sidebar from '../../components/Sidebar';
import { Container, Row, Col, Modal, Form, Alert, Button as BootstrapButton } from 'react-bootstrap';
import { ActionIcon, Group, TextInput, Button, Text, Badge, Stack } from '@mantine/core';
import { IconEye, IconEdit, IconTrash, IconPlus, IconSearch, IconX, IconPackage } from '@tabler/icons-react';
import { getProducts, createProduct, updateProduct, deleteProduct } from '../../api/model/product';
function Products() {
const [products, setProducts] = useState([]);
const [fetching, setFetching] = useState(true);
const [query, setQuery] = useState('');
const [showModal, setShowModal] = useState(false);
const [modalType, setModalType] = useState('view'); // view | edit | create
const [selectedProduct, setSelectedProduct] = useState(null);
const [error, setError] = useState(null);
const [submitting, setSubmitting] = useState(false);
const [formData, setFormData] = useState({ name: '', code: '' });
const [eventFilter, setEventFilter] = useState([]);
const [availableEvents, setAvailableEvents] = useState([]);
const fetchData = async () => {
setFetching(true);
try {
const data = await getProducts();
setProducts(Array.isArray(data) ? data : []);
} finally {
setFetching(false);
}
};
useEffect(() => { fetchData(); }, []);
const filtered = useMemo(() => {
let data = Array.isArray(products) ? products : [];
if (query) {
const q = query.toLowerCase();
data = data.filter(p => p.name?.toLowerCase().includes(q) || String(p.id).includes(q) || String(p.code || '').includes(q));
}
if (eventFilter.length > 0) {
data = data.filter(p => {
const evIds = (p.events || []).map(ev => String(ev.id));
return eventFilter.every(f => evIds.includes(f));
});
}
return data;
}, [products, query, eventFilter]);
useEffect(() => {
const map = new Map();
products.forEach(p => (p.events || []).forEach(ev => { if (!map.has(ev.id)) map.set(ev.id, ev.name); }));
setAvailableEvents(Array.from(map.entries()).map(([id, name]) => ({ value: String(id), label: name })));
}, [products]);
const openView = (product) => { setSelectedProduct(product); setModalType('view'); setShowModal(true); };
const openEdit = (product) => { setSelectedProduct(product); setFormData({ name: product.name || '', code: product.code || '' }); setModalType('edit'); setShowModal(true); };
const openCreate = () => { setSelectedProduct(null); setFormData({ name: '', code: '' }); setModalType('create'); setShowModal(true); };
const handleDelete = async (product) => {
if (!product) return;
if (window.confirm(`Opravdu smazat produkt: ${product.name || product.id}?`)) {
await deleteProduct(product.id);
fetchData();
}
};
const handleFieldChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
const submitEdit = async (e) => {
e.preventDefault();
setSubmitting(true); setError(null);
try {
await updateProduct(selectedProduct.id, { name: formData.name, code: formData.code || null });
setShowModal(false); setSelectedProduct(null); fetchData();
} catch (err) {
setError(formatErrors(err));
} finally { setSubmitting(false); }
};
const submitCreate = async (e) => {
e.preventDefault();
setSubmitting(true); setError(null);
try {
await createProduct({ name: formData.name, code: formData.code || null });
setShowModal(false); fetchData();
} catch (err) {
setError(formatErrors(err));
} finally { setSubmitting(false); }
};
const formatErrors = (err) => {
const apiErrors = err.response?.data;
if (typeof apiErrors === 'object') {
return Object.entries(apiErrors).map(([k,v]) => `${k}: ${Array.isArray(v)? v.join(', '): v}`).join('\n');
}
return err.message || 'Neznámá chyba';
};
const columns = [
{ accessor: 'id', title: 'ID', sortable: true, width: '70px' },
{
accessor: 'name',
title: 'Název',
sortable: true,
width: '2fr',
render: row => row.name || '—',
filter: (
<TextInput
label='Hledat'
placeholder='Název, ID, kód'
leftSection={<IconSearch size={16} />}
rightSection={<ActionIcon size='sm' variant='transparent' c='dimmed' onClick={() => setQuery('')}><IconX size={14} /></ActionIcon>}
value={query}
onChange={e => setQuery(e.currentTarget.value)}
/>
),
filtering: query !== '',
},
{
accessor: 'code',
title: 'Kód',
sortable: true,
width: '1fr',
render: row => row.code ?? '—',
},
{
accessor: 'events',
title: 'Akce',
width: '2fr',
render: row => (row.events && row.events.length > 0 ? row.events.map(e => e.name).join(', ') : '—'),
filter: (() => {
const toggle = (val) => {
setEventFilter(prev => prev.includes(val) ? prev.filter(v => v !== val) : [...prev, val]);
};
return (
<Stack gap={4} style={{ minWidth: 180 }}>
<Text fw={500} size='xs' c='dimmed'>Filtrovat akce</Text>
<Group gap={6} wrap='wrap'>
{availableEvents.map(opt => {
const active = eventFilter.includes(opt.value);
return (
<Badge
key={opt.value}
color={active ? 'blue' : 'gray'}
variant={active ? 'filled' : 'outline'}
style={{ cursor:'pointer' }}
onClick={() => toggle(opt.value)}
aria-pressed={active}
role='button'
>
{opt.label}
</Badge>
);
})}
</Group>
{eventFilter.length > 0 && (
<Button size='xs' variant='light' onClick={() => setEventFilter([])}>Reset</Button>
)}
</Stack>
);
})(),
filtering: eventFilter.length > 0,
},
{
accessor: 'actions',
title: 'Akce',
width: '110px',
render: (product) => (
<Group gap={4} wrap='nowrap'>
<ActionIcon size='sm' variant='subtle' color='green' onClick={() => openView(product)}><IconEye size={16} /></ActionIcon>
<ActionIcon size='sm' variant='subtle' color='blue' onClick={() => openEdit(product)}><IconEdit size={16} /></ActionIcon>
<ActionIcon size='sm' variant='subtle' color='red' onClick={() => handleDelete(product)}><IconTrash size={16} /></ActionIcon>
</Group>
),
},
];
return (
<Container fluid className='p-0 d-flex flex-column' style={{ overflowX:'hidden', height:'100vh' }}>
<Row className='mx-0 flex-grow-1'>
<Col xs={2} className='px-0 bg-light' style={{ minWidth:0 }}>
<Sidebar />
</Col>
<Col xs={10} className='px-0 bg-white d-flex flex-column' style={{ minWidth:0 }}>
<Group justify='space-between' align='center' px='md' py='sm'>
<h1><IconPackage size={30} style={{ marginRight:10, marginTop:-4 }} />Produkty</h1>
<Button component='a' onClick={(e)=> { e.preventDefault(); openCreate(); }} leftSection={<IconPlus size={16} />}>Přidat produkt</Button>
</Group>
<Table
data={filtered}
columns={columns}
fetching={fetching}
withTableBorder
borderRadius='md'
highlightOnHover
verticalAlign='center'
titlePadding='4px 8px'
/>
{/* Unified modal */}
<Modal show={showModal} onHide={() => setShowModal(false)} centered>
<Modal.Header closeButton><Modal.Title>{modalType === 'create' ? 'Nový produkt' : modalType === 'edit' ? 'Upravit produkt' : 'Detail produktu'}</Modal.Title></Modal.Header>
{modalType === 'view' && (
<>
<Modal.Body>
{selectedProduct ? (
<>
<p><strong>ID:</strong> {selectedProduct.id}</p>
<p><strong>Název:</strong> {selectedProduct.name || '—'}</p>
<p><strong>Kód:</strong> {selectedProduct.code ?? '—'}</p>
<p><strong>Akce:</strong> {selectedProduct.events?.length ? selectedProduct.events.map(e => e.name).join(', ') : '—'}</p>
</>
) : <Text>Produkt nebyl nalezen</Text>}
</Modal.Body>
<Modal.Footer>
<BootstrapButton variant='secondary' onClick={()=>setShowModal(false)}>Zavřít</BootstrapButton>
{selectedProduct && <BootstrapButton variant='primary' onClick={()=>{ openEdit(selectedProduct); }}>Upravit</BootstrapButton>}
</Modal.Footer>
</>
)}
{(modalType === 'edit' || modalType === 'create') && (
<Form onSubmit={modalType === 'edit' ? submitEdit : submitCreate}>
<Modal.Body>
<Form.Group className='mb-3'>
<Form.Label>Název</Form.Label>
<Form.Control name='name' value={formData.name} onChange={handleFieldChange} required />
</Form.Group>
<Form.Group className='mb-3'>
<Form.Label>Kód (volitelné)</Form.Label>
<Form.Control name='code' value={formData.code} onChange={handleFieldChange} />
</Form.Group>
{error && <Alert variant='danger' className='mb-0'>{error}</Alert>}
</Modal.Body>
<Modal.Footer>
<BootstrapButton variant='secondary' onClick={()=>setShowModal(false)}>Zrušit</BootstrapButton>
<BootstrapButton type='submit' variant='primary' disabled={submitting}>{modalType === 'edit' ? 'Uložit změny' : 'Vytvořit'}</BootstrapButton>
</Modal.Footer>
</Form>
)}
</Modal>
</Col>
</Row>
</Container>
);
}
export default Products;

View File

@@ -0,0 +1,670 @@
import Table from "../../components/Table";
import Sidebar from "../../components/Sidebar";
import { getReservations, deleteReservation, updateReservation } from "../../api/model/reservation";
import { IconEye, IconEdit, IconTrash, IconPlus, IconSearch, IconX, IconReceipt2 } from "@tabler/icons-react";
import { useEffect, useState } from "react";
import { Container, Row, Col, Form, Modal, Button as BootstrapButton } from "react-bootstrap";
import {
ActionIcon,
Button,
Stack,
Text,
Group,
Badge,
TextInput,
NumberInput
} from "@mantine/core";
import { DateInput } from '@mantine/dates';
import dayjs from "dayjs";
import 'dayjs/locale/cs';
// Set global locale for dayjs (affects formatting)
dayjs.locale('cs');
function Reservations() {
// Modal state for view/edit
const [showModal, setShowModal] = useState(false);
const [modalType, setModalType] = useState('view'); // 'view', 'edit'
const [selectedReservation, setSelectedReservation] = useState(null);
const [formData, setFormData] = useState({
status: "",
note: "",
final_price: "",
});
const [error, setError] = useState(null);
const [submitting, setSubmitting] = useState(false);
// Open view modal
const handleShowReservationModal = (reservation) => {
setSelectedReservation(reservation);
setModalType('view');
setShowModal(true);
};
// Open edit modal
const handleEditReservationModal = (reservation) => {
setSelectedReservation(reservation);
setFormData({
status: reservation.status || "",
note: reservation.note || "",
final_price: reservation.final_price || "",
});
setModalType('edit');
setShowModal(true);
};
// Handle form field changes
const handleChange = (e) => {
const { name, value } = e.target;
setFormData((old) => ({ ...old, [name]: value }));
};
// Submit edit modal
const handleEditModalSubmit = async (e) => {
e.preventDefault();
setError(null);
setSubmitting(true);
try {
await updateReservation(selectedReservation.id, {
status: formData.status,
note: formData.note,
final_price: formData.final_price,
});
setShowModal(false);
setFormData({ status: "", note: "", final_price: "" });
fetchReservations();
} catch (err) {
const apiErrors = err.response?.data;
if (typeof apiErrors === "object") {
const messages = Object.entries(apiErrors)
.map(([key, value]) => `${key}: ${Array.isArray(value) ? value.join(", ") : value}`)
.join("\n");
setError("Chyba při ukládání:\n" + messages);
} else {
setError("Chyba při ukládání: " + (err.message || "Neznámá chyba"));
}
} finally {
setSubmitting(false);
}
};
const [reservations, setReservations] = useState([]);
const [fetching, setFetching] = useState(true);
const [query, setQuery] = useState(""); // generic search across several text fields
const [selectedStatus, setSelectedStatus] = useState([]);
// New filter states
const [eventFilter, setEventFilter] = useState("");
const [createdFrom, setCreatedFrom] = useState(null);
const [createdTo, setCreatedTo] = useState(null);
const [reservedFromDate, setReservedFromDate] = useState(null); // single date for "Od"
const [reservedToDate, setReservedToDate] = useState(null); // single date for "Do"
const [priceMin, setPriceMin] = useState("");
const [priceMax, setPriceMax] = useState("");
// Status options for filter
const statusOptions = [
{ value: "reserved", label: "Rezervováno" },
{ value: "cancelled", label: "Zrušeno" },
{ value: "completed", label: "Dokončeno" },
{ value: "pending", label: "Čekající" },
];
// Filtering (pattern as in Users.jsx)
const filteredReservations = reservations.filter(r => {
let match = true;
if (query) {
const q = query.toLowerCase();
match = (
r.user?.username?.toLowerCase().includes(q) ||
r.event?.name?.toLowerCase().includes(q) ||
String(r.id).includes(q) ||
r.status?.toLowerCase().includes(q) ||
r.note?.toLowerCase().includes(q)
);
}
if (match && eventFilter) {
const ev = eventFilter.toLowerCase();
match = r.event?.name?.toLowerCase().includes(ev);
}
if (match && selectedStatus.length > 0) {
match = selectedStatus.includes(r.status);
}
if (match && createdFrom) {
match = r.created_at && dayjs(r.created_at).isAfter(dayjs(createdFrom).startOf('day').subtract(1, 'millisecond'));
}
if (match && createdTo) {
match = r.created_at && dayjs(r.created_at).isBefore(dayjs(createdTo).endOf('day').add(1, 'millisecond'));
}
if (match && reservedFromDate) {
match = r.reserved_from && dayjs(r.reserved_from, 'YYYY-MM-DD').isSame(dayjs(reservedFromDate), 'day');
}
if (match && reservedToDate) {
match = r.reserved_to && dayjs(r.reserved_to, 'YYYY-MM-DD').isSame(dayjs(reservedToDate), 'day');
}
if (match && priceMin !== "") {
match = (r.final_price ?? 0) >= parseFloat(priceMin || 0);
}
if (match && priceMax !== "") {
match = (r.final_price ?? 0) <= parseFloat(priceMax || 0);
}
return match;
});
const fetchReservations = async () => {
setFetching(true);
try {
const params = { search: query };
const data = await getReservations(params);
setReservations(data);
} finally {
setFetching(false);
}
};
useEffect(() => {
fetchReservations();
}, [query]);
// Remove unused Mantine modal logic
// Use only Bootstrap modals for view/edit
const handleDeleteReservation = async (reservation) => {
if (window.confirm(`Opravdu smazat rezervaci ID: ${reservation.id}?`)) {
await deleteReservation(reservation.id);
fetchReservations();
}
};
const statusColors = {
reserved: "blue",
cancelled: "red",
completed: "green",
pending: "yellow",
};
const columns = [
{ accessor: "id", title: "#", sortable: true, width: "48px" },
{
accessor: "user",
title: "Uživatel",
sortable: true,
width: "1.5fr",
render: row => row.user?.username || row.user || "—",
filter: (
<TextInput
label="Hledat uživatele"
placeholder="Např. jméno, email..."
leftSection={<IconSearch size={16} />}
rightSection={
<ActionIcon size="sm" variant="transparent" c="dimmed" onClick={() => setQuery("")}>
<IconX size={14} />
</ActionIcon>
}
value={query}
onChange={e => setQuery(e.currentTarget.value)}
/>
),
filtering: query !== "",
},
{
accessor: "event",
title: "Událost",
sortable: true,
width: "2fr",
render: row => row.event?.name || "—",
filter: (
<TextInput
label="Filtrovat událost"
placeholder="Název události"
leftSection={<IconSearch size={16} />}
rightSection={
<ActionIcon size="sm" variant="transparent" c="dimmed" onClick={() => setEventFilter("")}>
<IconX size={14} />
</ActionIcon>
}
value={eventFilter}
onChange={e => setEventFilter(e.currentTarget.value)}
/>
),
filtering: !!eventFilter,
},
{
accessor: "market_slot",
title: "Prodejní místo",
sortable: true,
width: "1.2fr",
render: row => row.market_slot?.name || row.market_slot?.id || "—",
},
{
accessor: "used_extension",
title: "Rozšíření (m²)",
sortable: true,
width: "1fr",
render: row => row.used_extension ?? 0,
},
{
accessor: "reserved_from",
title: "Od",
sortable: true,
width: "1.2fr",
render: row => row.reserved_from ? dayjs(row.reserved_from, "YYYY-MM-DD").format("DD.MM.YYYY") : "—",
filter: (
<DateInput
value={reservedFromDate}
onChange={setReservedFromDate}
label="Datum Od"
valueFormat="DD.MM.YYYY"
locale="cs"
clearable
/>
),
filtering: !!reservedFromDate,
},
{
accessor: "reserved_to",
title: "Do",
sortable: true,
width: "1.2fr",
render: row => row.reserved_to ? dayjs(row.reserved_to, "YYYY-MM-DD").format("DD.MM.YYYY") : "—",
filter: (
<DateInput
value={reservedToDate}
onChange={setReservedToDate}
label="Datum Do"
valueFormat="DD.MM.YYYY"
locale="cs"
clearable
/>
),
filtering: !!reservedToDate,
},
{
accessor: "created_at",
title: "Vytvořeno",
sortable: true,
width: "1.2fr",
render: row => row.created_at ? dayjs(row.created_at).format("DD.MM.YYYY HH:mm") : "—",
filter: (
<Stack gap={4}>
<DateInput
value={createdFrom}
onChange={setCreatedFrom}
label="Vytvořeno od"
valueFormat="DD.MM.YYYY"
locale="cs"
clearable
/>
<DateInput
value={createdTo}
onChange={setCreatedTo}
label="Vytvořeno do"
valueFormat="DD.MM.YYYY"
locale="cs"
clearable
/>
</Stack>
),
filtering: !!(createdFrom || createdTo),
},
{
accessor: "status",
title: "Stav",
sortable: true,
width: "1fr",
render: row => {
const color = statusColors[row.status] || "gray";
const label = statusOptions.find(opt => opt.value === row.status)?.label || row.status;
return <Badge color={color} variant="light">{label}</Badge>;
},
filter: (() => {
const toggleStatus = (value) => {
setSelectedStatus(prev => prev.includes(value) ? prev.filter(v => v !== value) : [...prev, value]);
};
return (
<Stack gap={4} style={{ minWidth: 160 }}>
<Text fw={500} size="xs" c="dimmed">Filtrovat stav</Text>
<Group gap={6} wrap="wrap">
{statusOptions.map(opt => {
const active = selectedStatus.includes(opt.value);
return (
<Badge
key={opt.value}
color={statusColors[opt.value] || "gray"}
variant={active ? "filled" : "outline"}
style={{ cursor: 'pointer', userSelect: 'none' }}
onClick={() => toggleStatus(opt.value)}
aria-pressed={active}
role="button"
>
{opt.label}
</Badge>
);
})}
</Group>
{selectedStatus.length > 0 && (
<Button size="xs" variant="light" onClick={() => setSelectedStatus([])}>Reset</Button>
)}
</Stack>
);
})(),
filtering: selectedStatus.length > 0,
},
{
accessor: "note",
title: "Poznámka",
sortable: false,
width: "2fr",
render: row => row.note || "—",
},
{
accessor: "final_price",
title: "Cena (Kč)",
sortable: true,
width: "1fr",
render: row => row.final_price ?? 0,
filter: (
<Stack gap={4}>
<NumberInput
label="Cena od"
value={priceMin === "" ? undefined : Number(priceMin)}
onChange={(v) => setPriceMin(v === undefined || v === null ? "" : String(v))}
min={0}
thousandSeparator=" "
/>
<NumberInput
label="Cena do"
value={priceMax === "" ? undefined : Number(priceMax)}
onChange={(v) => setPriceMax(v === undefined || v === null ? "" : String(v))}
min={0}
thousandSeparator=" "
/>
{(priceMin !== "" || priceMax !== "") && (
<Button size="xs" variant="light" onClick={() => { setPriceMin(""); setPriceMax(""); }}>Reset ceny</Button>
)}
</Stack>
),
filtering: priceMin !== "" || priceMax !== "",
},
{
accessor: "actions",
title: "Akce",
width: "80px",
render: (reservation) => (
<Group gap={4} wrap="nowrap">
<ActionIcon size="sm" variant="subtle" color="green" onClick={() => handleShowReservationModal(reservation)}>
<IconEye size={16} />
</ActionIcon>
<ActionIcon size="sm" variant="subtle" color="blue" onClick={() => handleEditReservationModal(reservation)}>
<IconEdit size={16} />
</ActionIcon>
<ActionIcon size="sm" variant="subtle" color="red" onClick={() => handleDeleteReservation(reservation)}>
<IconTrash size={16} />
</ActionIcon>
</Group>
),
},
];
const renderModalContent = () => {
if (!selectedReservation) return <Text>Rezervace nebyla nalezena</Text>;
switch (modalType) {
case "view":
return (
<Stack>
<Text>
<strong>ID:</strong> {selectedReservation.id}
</Text>
<Text>
<strong>Stav:</strong>{" "}
<Badge color={statusColors[selectedReservation.status] || "gray"}>
{selectedReservation.status}
</Badge>
</Text>
<Text>
<strong>Událost:</strong> #{selectedReservation.event}
</Text>
<Text>
<strong>Pozice:</strong> #{selectedReservation.marketSlot}
</Text>
<Text>
<strong>Uživatel:</strong> #{selectedReservation.user}
</Text>
<Text>
<strong>Rozšíření:</strong>{" "}
{selectedReservation.used_extension || "Žádné"}
</Text>
<Text>
<strong>Od:</strong>{" "}
{dayjs(selectedReservation.reserved_from).format(
"DD.MM.YYYY HH:mm"
)}
</Text>
<Text>
<strong>Do:</strong>{" "}
{dayjs(selectedReservation.reserved_to).format("DD.MM.YYYY HH:mm")}
</Text>
<Text>
<strong>Vytvořeno:</strong>{" "}
{dayjs(selectedReservation.created_at).format("DD.MM.YYYY HH:mm")}
</Text>
<Text>
<strong>Poznámka:</strong> {selectedReservation.note || "—"}
</Text>
<Text>
<strong>Cena:</strong> {selectedReservation.final_price}
</Text>
<Group mt="md">
<Button variant="outline" onClick={() => setShowModal(false)}>
Zavřít
</Button>
<Button onClick={() => { setShowModal(false); handleEditReservationModal(selectedReservation); }}>
Upravit
</Button>
</Group>
</Stack>
);
case "edit":
return (
<Stack>
<Select
label="Stav rezervace"
defaultValue={selectedReservation.status}
data={[
{ value: "reserved", label: "Rezervováno" },
{ value: "cancelled", label: "Zrušeno" },
{ value: "completed", label: "Dokončeno" },
{ value: "pending", label: "Čekající" },
]}
mb="sm"
/>
<TextInput
label="Událost"
defaultValue={`#${selectedReservation.event}`}
disabled
mb="sm"
/>
<TextInput
label="Uživatel"
defaultValue={`#${selectedReservation.user}`}
disabled
mb="sm"
/>
<TextInput
label="Pozice"
defaultValue={`#${selectedReservation.marketSlot}`}
disabled
mb="sm"
/>
<TextInput
label="Od"
defaultValue={dayjs(selectedReservation.reserved_from).format(
"DD.MM.YYYY HH:mm"
)}
disabled
mb="sm"
/>
<TextInput
label="Do"
defaultValue={dayjs(selectedReservation.reserved_to).format(
"DD.MM.YYYY HH:mm"
)}
disabled
mb="sm"
/>
<NumberInput
label="Cena (Kč)"
defaultValue={parseFloat(selectedReservation.final_price)}
min={0}
precision={2}
mb="sm"
/>
<TextInput
label="Poznámka"
defaultValue={selectedReservation.note}
mb="sm"
/>
<Group mt="md">
<Button variant="outline" onClick={() => setShowModal(false)}>
Zrušit
</Button>
<Button color="blue" onClick={() => setShowModal(false)}>
Uložit změny
</Button>
</Group>
</Stack>
);
case "delete":
return (
<Stack>
<Text>
Opravdu chcete smazat rezervaci ID: {selectedReservation.id}?
</Text>
<Group mt="md">
<Button variant="outline" onClick={() => setShowModal(false)}>
Zrušit
</Button>
<Button color="red" onClick={handleConfirmDelete}>
Smazat
</Button>
</Group>
</Stack>
);
default:
return null;
}
};
const getModalTitle = () => {
if (!selectedReservation) return "Detail rezervace";
switch (modalType) {
case "view":
return `Rezervace #${selectedReservation.id}`;
case "edit":
return `Upravit rezervaci #${selectedReservation.id}`;
case "delete":
return `Smazat rezervaci`;
default:
return "Detail rezervace";
}
};
return (
<Container fluid className="p-0 d-flex flex-column" style={{ overflowX: "hidden", height: "100vh" }}>
<Row className="mx-0 flex-grow-1">
<Col xs={2} className="px-0 bg-light" style={{ minWidth: 0 }}>
<Sidebar />
</Col>
<Col xs={10} className="px-0 bg-white d-flex flex-column" style={{ minWidth: 0 }}>
<Group justify="space-between" align="center" px="md" py="sm">
<h1>
<IconReceipt2 size={30} style={{ marginRight: 10, marginTop: -4 }} />
Rezervace
</h1>
<Button component="a" href="/create-reservation" leftSection={<IconPlus size={16} />}>Přidat rezervaci</Button>
</Group>
<Table
data={filteredReservations}
columns={columns}
fetching={fetching}
withTableBorder
borderRadius="md"
highlightOnHover
verticalAlign="center"
titlePadding="4px 8px"
/>
{/* Bootstrap Modal for view */}
<Modal show={showModal && modalType === 'view'} onHide={() => setShowModal(false)} centered>
<Modal.Header closeButton>
<Modal.Title>Detail rezervace</Modal.Title>
</Modal.Header>
<Modal.Body>
{selectedReservation && (
<>
<p><strong>ID:</strong> {selectedReservation.id}</p>
<p><strong>Stav:</strong> {selectedReservation.status}</p>
<p><strong>Událost:</strong> {selectedReservation.event?.name || "Neznámá událost"}</p>
<p><strong>Uživatel:</strong> {selectedReservation.user?.username || "Neznámý"}</p>
<p><strong>Od:</strong> {dayjs(selectedReservation.reserved_from, "YYYY-MM-DD").format("DD.MM.YYYY")}</p>
<p><strong>Do:</strong> {dayjs(selectedReservation.reserved_to, "YYYY-MM-DD").format("DD.MM.YYYY")}</p>
<p><strong>Poznámka:</strong> {selectedReservation.note || "—"}</p>
<p><strong>Cena:</strong> {selectedReservation.final_price} </p>
</>
)}
</Modal.Body>
<Modal.Footer>
<BootstrapButton variant="outline" onClick={() => setShowModal(false)}>Zavřít</BootstrapButton>
<BootstrapButton variant="primary" onClick={() => { setShowModal(false); handleEditReservationModal(selectedReservation); }}>Upravit</BootstrapButton>
</Modal.Footer>
</Modal>
{/* Bootstrap Modal for edit */}
<Modal show={showModal && modalType === 'edit'} onHide={() => setShowModal(false)} centered>
<Modal.Header closeButton>
<Modal.Title>Upravit rezervaci</Modal.Title>
</Modal.Header>
<Form onSubmit={handleEditModalSubmit}>
<Modal.Body>
<Form.Group className="mb-3">
<Form.Label>Stav rezervace</Form.Label>
<Form.Control as="select" name="status" value={formData.status} onChange={handleChange} required>
<option value="reserved">Rezervováno</option>
<option value="cancelled">Zrušeno</option>
<option value="completed">Dokončeno</option>
<option value="pending">Čekající</option>
</Form.Control>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Poznámka</Form.Label>
<Form.Control as="textarea" rows={4} name="note" value={formData.note} onChange={handleChange} />
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Cena ()</Form.Label>
<Form.Control type="number" name="final_price" value={formData.final_price} onChange={handleChange} />
</Form.Group>
{error && <Text color="red">{error}</Text>}
</Modal.Body>
<Modal.Footer>
<BootstrapButton variant="outline" onClick={() => setShowModal(false)}>Zrušit</BootstrapButton>
<BootstrapButton type="submit" variant="primary" disabled={submitting}>Uložit změny</BootstrapButton>
</Modal.Footer>
</Form>
</Modal>
</Col>
</Row>
</Container>
);
}
export default Reservations;

View File

@@ -0,0 +1,269 @@
import React, { useState, useRef } from "react";
import { useNavigate } from "react-router-dom";
import squareAPI from "../../../api/model/square";
import { Container, Row, Col, Form, Button, Alert, Modal } from "react-bootstrap";
import DynamicGrid, { DEFAULT_CONFIG } from "../../../components/DynamicGrid";
export default function SquareDesigner() {
const navigate = useNavigate();
const [step, setStep] = useState(1);
const [image, setImage] = useState(null);
const [imageUrl, setImageUrl] = useState("");
const [formData, setFormData] = useState({
name: "",
description: "",
street: "",
city: "",
psc: "",
width: 40,
height: 30,
cell_area: 4, // m2, default
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [success, setSuccess] = useState(false);
const [showSuccessModal, setShowSuccessModal] = useState(false);
const [imageAspect, setImageAspect] = useState(4/3); // default aspect ratio
const [error, setError] = useState(null);
const fileInputRef = useRef();
// Calculate grid size from width, height, and cell_area
const cellArea = Math.max(1, Math.floor(Number(formData.cell_area) || 1));
// cell is always square in m², so side = sqrt(cellArea)
const cellSide = Math.sqrt(cellArea);
const safeWidth = Math.max(1, Number(formData.width) || 1);
const safeHeight = Math.max(1, Number(formData.height) || 1);
let grid_cols = Math.max(1, Math.round(safeWidth / cellSide));
let grid_rows = Math.max(1, Math.round(safeHeight / cellSide));
// Prevent NaN or Infinity
if (!isFinite(grid_cols) || grid_cols < 1) grid_cols = 1;
if (!isFinite(grid_rows) || grid_rows < 1) grid_rows = 1;
const cellWidth = safeWidth / grid_cols;
const cellHeight = safeHeight / grid_rows;
const handleChange = (e) => {
const { name, value } = e.target;
setFormData((old) => ({ ...old, [name]: value }));
};
const handleImageChange = (e) => {
const file = e.target.files[0];
if (file) {
setImage(file);
const url = URL.createObjectURL(file);
setImageUrl(url);
// Get image aspect ratio
const img = new window.Image();
img.onload = () => {
const aspect = img.width / img.height;
setImageAspect(aspect);
// Adjust width/height to match aspect
setFormData((old) => ({
...old,
width: Math.sqrt((old.cell_area || 1) * aspect),
height: Math.sqrt((old.cell_area || 1) / aspect),
}));
};
img.src = url;
setStep(2);
}
};
// Only allow width/height to be changed together (scaling)
const handleScale = (delta) => {
setFormData((old) => {
const scale = Math.max(0.1, 1 + delta);
const newWidth = old.width * scale;
const newHeight = newWidth / imageAspect;
return {
...old,
width: newWidth,
height: newHeight,
area: newWidth * newHeight,
};
});
};
// When user sets area, recalc width/height
const handleAreaChange = (e) => {
const area = Number(e.target.value) || 1;
setFormData((old) => ({
...old,
area,
width: Math.sqrt(area * imageAspect),
height: Math.sqrt(area / imageAspect),
}));
};
const handleImageRemove = () => {
setImage(null);
setImageUrl("");
setStep(1);
if (fileInputRef.current) fileInputRef.current.value = "";
};
return (
<Container className="py-4">
<h2>Návrh náměstí / Square Designer</h2>
{step === 1 && (
<Row className="mb-4">
<Col md={6}>
<Form.Group className="mb-3">
<Form.Label>Nejprve nahrajte obrázek náměstí (doporučeno z ptačí perspektivy)</Form.Label>
<Form.Control type="file" accept="image/*" onChange={handleImageChange} ref={fileInputRef} />
</Form.Group>
</Col>
</Row>
)}
{step === 2 && (
<>
<Row className="mb-4">
<Col md={7}>
<h5>Editor mapy náměstí</h5>
<div style={{
position: "relative",
width: "100%",
maxWidth: 600,
aspectRatio: safeWidth / safeHeight || 1,
backgroundImage: imageUrl ? `url(${imageUrl})` : undefined, // only use uploaded image
backgroundSize: "contain",
backgroundRepeat: "no-repeat",
backgroundPosition: "center",
backgroundColor: "#f8f9fa",
border: "2px solid #007bff",
borderRadius: 8,
overflow: "hidden",
marginBottom: 24
}}>
{grid_cols > 0 && grid_rows > 0 && isFinite(grid_cols) && isFinite(grid_rows) && (
<DynamicGrid
config={{
cols: grid_cols,
rows: grid_rows,
cellSize: 24,
gridColor: "#007bff",
gridThickness: 2,
}}
reservations={[]}
static={true}
backgroundImage={imageUrl} // <-- pass imageUrl here
/>
)}
</div>
<div className="mt-2 mb-4">
<div>Každá buňka: <b>{cellWidth.toFixed(2)}m × {cellHeight.toFixed(2)}m</b> ({cellArea} )</div>
<div>Počet řádků: <b>{grid_rows}</b> | Počet sloupců: <b>{grid_cols}</b></div>
</div>
</Col>
<Col md={5}>
<Form>
<Form.Group className="mb-3">
<Form.Label>Šířka náměstí (m)</Form.Label>
<Form.Control type="number" name="width" value={formData.width} onChange={handleChange} min={1} step={1} />
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Výška náměstí (m)</Form.Label>
<Form.Control type="number" name="height" value={formData.height} onChange={handleChange} min={1} step={1} />
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Velikost jedné buňky (, pouze celé číslo)</Form.Label>
<Form.Control type="number" name="cell_area" value={formData.cell_area} onChange={handleChange} min={1} step={1} />
</Form.Group>
</Form>
</Col>
</Row>
<Row>
<Col md={8} lg={6} xl={5}>
<Form>
<Form.Group className="mb-3">
<Form.Label>Název náměstí</Form.Label>
<Form.Control name="name" value={formData.name} onChange={handleChange} required />
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Popis</Form.Label>
<Form.Control as="textarea" name="description" value={formData.description} onChange={handleChange} rows={2} />
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Ulice</Form.Label>
<Form.Control name="street" value={formData.street} onChange={handleChange} />
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Město</Form.Label>
<Form.Control name="city" value={formData.city} onChange={handleChange} />
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>PSČ</Form.Label>
<Form.Control name="psc" value={formData.psc} onChange={handleChange} type="number" min={10000} max={99999} />
</Form.Group>
<Button
variant="primary"
className="mt-2"
onClick={async (e) => {
e.preventDefault();
setError(null);
setSuccess(false);
setIsSubmitting(true);
try {
const data = new FormData();
data.append("name", formData.name);
data.append("description", formData.description);
data.append("street", formData.street);
data.append("city", formData.city);
data.append("psc", formData.psc);
data.append("width", formData.width);
data.append("height", formData.height);
data.append("grid_rows", grid_rows);
data.append("grid_cols", grid_cols);
data.append("cellsize", cellArea);
if (image) data.append("image", image); // <-- send image file
await squareAPI.createSquare(data); // <-- send FormData with image
setSuccess(true);
setShowSuccessModal(true);
setTimeout(() => {
setShowSuccessModal(false);
navigate("/manage/squares");
window.location.reload(); // <-- force reload after redirect
}, 1400);
} catch (err) {
setError("Chyba při vytváření náměstí. Zkontrolujte data.");
} finally {
setIsSubmitting(false);
}
}}
disabled={isSubmitting || !formData.name || !formData.city || !formData.psc || !formData.width || !formData.height}
>
{isSubmitting ? "Ukládám..." : "Vytvořit nové náměstí"}
</Button>
{success && <Alert variant="success" className="mt-3">Náměstí bylo úspěšně vytvořeno!</Alert>}
{error && <Alert variant="danger" className="mt-3">{error}</Alert>}
<Button variant="outline-danger" onClick={handleImageRemove} className="mt-3 ms-2">Změnit obrázek</Button>
</Form>
</Col>
</Row>
</>
)}
{error && <Alert variant="danger">{error}</Alert>}
<Button variant="secondary" href="/manage/squares">Zpět na seznam náměstí</Button>
{/* Success Modal with animated checkmark */}
<Modal show={showSuccessModal} centered backdrop="static" keyboard={false} contentClassName="text-center p-4">
<div style={{ fontSize: 80, color: '#28a745', marginBottom: 16, animation: 'pop 0.5s cubic-bezier(.68,-0.55,.27,1.55)' }}>
<svg width="80" height="80" viewBox="0 0 80 80">
<circle cx="40" cy="40" r="38" fill="#eafaf1" stroke="#28a745" strokeWidth="4" />
<polyline points="24,44 36,56 56,28" fill="none" stroke="#28a745" strokeWidth="6" strokeLinecap="round" strokeLinejoin="round">
<animate attributeName="points" dur="0.5s" values="24,44 36,56 36,56;24,44 36,56 56,28" keyTimes="0;1" fill="freeze" />
</polyline>
</svg>
</div>
<h4 className="mb-0">Náměstí bylo úspěšně vytvořeno!</h4>
</Modal>
<style>{`
@keyframes pop {
0% { transform: scale(0.7); opacity: 0; }
80% { transform: scale(1.1); opacity: 1; }
100% { transform: scale(1); }
}
`}</style>
</Container>
);
}

View File

@@ -0,0 +1,368 @@
import React, { useEffect, useMemo, useState } from "react";
import Table from "../../components/Table";
import Sidebar from "../../components/Sidebar";
import {
Container,
Row,
Col,
Button as BootstrapButton,
Modal,
Form,
Alert,
} from "react-bootstrap";
import {
ActionIcon,
Group,
TextInput,
Text,
MultiSelect,
Stack,
Button
} from "@mantine/core";
import { IconSearch, IconX, IconEye, IconEdit, IconTrash, IconPlus, IconReceipt2 } from "@tabler/icons-react";
import apiSquares from "../../api/model/square";
function Squares() {
// Delete handler
const handleDeleteEvent = async (square) => {
if (window.confirm(`Opravdu smazat náměstí: ${square.name}?`)) {
await apiSquares.deleteSquare(square.id);
const data = await apiSquares.getSquares();
setSquares(data);
}
};
// Bootstrap Modal state for edit
const [showEditModal, setShowEditModal] = useState(false);
const handleEditSquare = (square) => {
setSelectedSquare(square);
setFormData({
name: square.name || "",
street: square.street || "",
city: square.city || "",
psc: square.psc || "",
});
setShowEditModal(true);
};
const handleEditModalSubmit = async (e) => {
e.preventDefault();
setError(null);
setSubmitting(true);
try {
const form = new FormData();
form.append("name", formData.name);
form.append("description", formData.description);
form.append("street", formData.street);
form.append("city", formData.city);
form.append("psc", Number(formData.psc));
if (formData.image instanceof File) {
form.append("image", formData.image);
}
await apiSquares.updateSquare(selectedSquare.id, form);
setShowEditModal(false);
setFormData({
name: "",
description: "",
street: "",
city: "",
psc: "",
});
const data = await apiSquares.getSquares();
setSquares(data);
} catch (err) {
const apiErrors = err.response?.data;
if (typeof apiErrors === "object") {
const messages = Object.entries(apiErrors)
.map(([key, value]) => `${key}: ${value.join(", ")}`)
.join("\n");
setError("Chyba při ukládání:\n" + messages);
} else {
setError("Chyba při ukládání: " + (err.message || "Neznámá chyba"));
}
} finally {
setSubmitting(false);
}
};
const [squares, setSquares] = useState([]);
const [fetching, setFetching] = useState(true);
const [query, setQuery] = useState("");
const [selectedCities, setSelectedCities] = useState([]);
// Modal state
const [showModal, setShowModal] = useState(false);
const [modalType, setModalType] = useState('view'); // 'view', 'edit'
const [selectedSquare, setSelectedSquare] = useState(null);
const [formData, setFormData] = useState({
name: "",
description: "",
street: "",
city: "",
psc: "",
});
const [error, setError] = useState(null);
const [submitting, setSubmitting] = useState(false);
// Fetch data from API
useEffect(() => {
const fetchData = async () => {
try {
const data = await apiSquares.getSquares();
setSquares(data);
} finally {
setFetching(false);
}
};
fetchData();
}, []);
// City options for filter
const cityOptions = useMemo(() => {
if (!Array.isArray(squares)) return [];
const uniqueCities = [...new Set(squares.map(r => r.city).filter(Boolean))];
return uniqueCities;
}, [squares]);
// Filtering (same pattern as Users.jsx)
const filteredSquares = useMemo(() => {
let data = Array.isArray(squares) ? squares : [];
if (query) {
const q = query.toLowerCase();
data = data.filter(
r =>
r.name?.toLowerCase().includes(q) ||
r.street?.toLowerCase().includes(q) ||
r.city?.toLowerCase().includes(q)
);
}
if (selectedCities.length > 0) {
data = data.filter(r => selectedCities.includes(r.city));
}
return data;
}, [squares, query, selectedCities]);
// Handle form field changes
const handleChange = (e) => {
const { name, value } = e.target;
setFormData((old) => ({ ...old, [name]: value }));
};
// Handlers for modal actions
const handleShowSquare = (square) => {
setSelectedSquare(square);
setModalType('view');
setShowModal(true);
};
const columns = [
{ accessor: "id", title: "#", sortable: true, width: "2%" },
{
accessor: "name",
title: "Název",
sortable: true,
width: "15%",
filter: (
<TextInput
label="Hledat názvy"
placeholder="Např. Trh, Koncert..."
leftSection={<IconSearch size={16} />}
rightSection={
<ActionIcon size="sm" variant="transparent" c="dimmed" onClick={() => setQuery("")}>
<IconX size={14} />
</ActionIcon>
}
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
/>
),
filtering: query !== "",
},
{ accessor: "description", title: "Popis", width: "18%" },
{ accessor: "street", title: "Ulice", sortable: true, width: "12%" },
{ accessor: "city", title: "Město", sortable: true, width: "15%",
filter: (
<MultiSelect
label="Filtrovat města"
placeholder="Vyber město/města"
data={cityOptions}
value={selectedCities}
onChange={setSelectedCities}
clearable
searchable
leftSection={<IconSearch size={16} />}
comboboxProps={{ withinPortal: false }}
/>
),
filtering: selectedCities.length > 0,
},
{ accessor: "psc", title: "PSČ", width: "4%" },
{
accessor: "velikost",
title: "Velikost m²",
width: "6%",
render: (row) => (row.width && row.height ? row.width * row.height + " m²" : "—"),
},
{ accessor: "cellsize", title: "Velikost buňky (m²)", width: "6%" },
{
accessor: "image",
title: "Obrázek",
width: "8%",
render: (row) =>
row.image ? (
<img src={row.image} alt={row.name} style={{ width: "100px", height: "auto", borderRadius: "8px" }} />
) : (
<Text c="dimmed" fs="italic">
Žádný obrázek
</Text>
),
},
{
accessor: "events",
title: "Počet událostí",
width: "5%",
textAlign: "center",
render: (row) => row.events?.length || 0,
sortable: true,
},
{
accessor: "actions",
title: "Akce",
width: "5.5%",
render: (square) => (
<Group gap={4} wrap="nowrap">
<ActionIcon size="sm" variant="subtle" color="green" onClick={() => handleShowSquare(square)}>
<IconEye size={16} />
</ActionIcon>
<ActionIcon size="sm" variant="subtle" color="blue" onClick={() => handleEditSquare(square)}>
<IconEdit size={16} />
</ActionIcon>
<ActionIcon size="sm" variant="subtle" color="red" onClick={() => handleDeleteEvent(square)}>
<IconTrash size={16} />
</ActionIcon>
</Group>
),
},
];
// Modal content for view/edit
const renderModalContent = () => {
// No longer used for view/edit, handled by Bootstrap modals below
return <Text>Žádný obsah</Text>;
};
return (
<Container fluid className="p-0 d-flex flex-column" style={{ overflowX: "hidden", height: "100vh" }}>
<Row className="mx-0 flex-grow-1">
<Col xs={2} className="px-0 bg-light" style={{ minWidth: 0 }}>
<Sidebar />
</Col>
<Col xs={10} className="px-0 bg-white d-flex flex-column" style={{ minWidth: 0 }}>
<Group justify="space-between" align="center" px="md" py="sm">
<h1>
<IconReceipt2 size={30} style={{ marginRight: 10, marginTop: -4 }} />
Náměstí
</h1>
<Button component="a" href="/manage/squares/designer" leftSection={<IconPlus size={16} />}>Přidat náměstí</Button>
</Group>
<Table
data={filteredSquares}
columns={columns}
fetching={fetching}
withTableBorder
borderRadius="md"
highlightOnHover
verticalAlign="center"
titlePadding="4px 8px"
/>
{/* Mantine modal for add only */}
<Modal
opened={showModal && modalType === 'add'}
onClose={() => setShowModal(false)}
title={'Přidat náměstí'}
size="lg"
centered
>
{renderModalContent()}
</Modal>
{/* Bootstrap Modal for view */}
<Modal show={showModal && modalType === 'view'} onHide={() => setShowModal(false)} centered>
<Modal.Header closeButton>
<Modal.Title>Detail náměstí</Modal.Title>
</Modal.Header>
<Modal.Body>
{selectedSquare && (
<>
<p><strong>ID:</strong> {selectedSquare.id}</p>
<p><strong>Název:</strong> {selectedSquare.name}</p>
<p><strong>Popis:</strong> {selectedSquare.description || "—"}</p>
<p><strong>Ulice:</strong> {selectedSquare.street || "Ulice není zadaná"}</p>
<p><strong>Město:</strong> {selectedSquare.city || "Město není zadané"}</p>
<p><strong>PSC:</strong> {selectedSquare.psc || 12345}</p>
<p><strong>Šířka:</strong> {selectedSquare.width ?? 10}</p>
<p><strong>Výška:</strong> {selectedSquare.height ?? 10}</p>
<p><strong>Grid řádky:</strong> {selectedSquare.grid_rows ?? 60}</p>
<p><strong>Grid sloupce:</strong> {selectedSquare.grid_cols ?? 45}</p>
<p><strong>Velikost buňky:</strong> {selectedSquare.cellsize ?? 10}</p>
<p><strong>Počet událostí:</strong> {selectedSquare.events?.length || 0}</p>
<p><strong>Obrázek:</strong><br />
{selectedSquare.image
? <img src={selectedSquare.image} alt={selectedSquare.name} style={{ width: "100px", borderRadius: "8px" }} />
: <span style={{ color: "#888", fontStyle: "italic" }}>Žádný obrázek</span>
}
</p>
</>
)}
</Modal.Body>
<Modal.Footer>
<BootstrapButton variant="secondary" onClick={() => setShowModal(false)}>Zavřít</BootstrapButton>
<BootstrapButton variant="primary" onClick={() => { setShowModal(false); handleEditSquare(selectedSquare); }}>Upravit</BootstrapButton>
</Modal.Footer>
</Modal>
{/* Bootstrap Modal for edit */}
<Modal show={showEditModal} onHide={() => setShowEditModal(false)} centered>
<Modal.Header closeButton>
<Modal.Title>Upravit náměstí</Modal.Title>
</Modal.Header>
<Form onSubmit={handleEditModalSubmit}>
<Modal.Body>
<Form.Group className="mb-3">
<Form.Label>Název</Form.Label>
<Form.Control name="name" value={formData.name} onChange={handleChange} required />
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Popis</Form.Label>
<Form.Control as="textarea" rows={4} name="description" value={formData.description} onChange={handleChange} />
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Ulice</Form.Label>
<Form.Control name="street" value={formData.street} onChange={handleChange} />
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Město</Form.Label>
<Form.Control name="city" value={formData.city} onChange={handleChange} />
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>PSC</Form.Label>
<Form.Control name="psc" value={formData.psc} onChange={handleChange} />
</Form.Group>
{error && <Alert variant="danger">{error}</Alert>}
</Modal.Body>
<Modal.Footer>
<BootstrapButton variant="secondary" onClick={() => setShowEditModal(false)}>Zrušit</BootstrapButton>
<BootstrapButton type="submit" variant="primary" disabled={submitting}>Uložit změny</BootstrapButton>
</Modal.Footer>
</Form>
</Modal>
</Col>
</Row>
</Container>
);
}
export default Squares;

View File

@@ -0,0 +1,13 @@
import UserSettings from "../../components/User-Settings";
function Settings(){
return(
<div>
<UserSettings />
</div>
)
}
export default Settings

View File

@@ -0,0 +1,795 @@
import React, { useEffect, useMemo, useState } from "react";
import Table from "../../components/Table";
import Sidebar from "../../components/Sidebar";
import {
Container,
Row,
Col,
Button as BootstrapButton,
Modal,
Form,
Alert,
} from "react-bootstrap";
import {
ActionIcon,
Group,
TextInput,
Text,
Stack,
Button,
Switch,
Badge,
Tooltip,
} from "@mantine/core";
import { IconSearch, IconX, IconEye, IconEdit, IconTrash, IconPlus, IconReceipt2 } from "@tabler/icons-react";
import userAPI from "../../api/model/user";
import { fetchEnumFromSchemaJson } from "../../api/get_chocies";
function Users() {
// State
const [users, setUsers] = useState([]);
const [fetching, setFetching] = useState(true);
// Separate filter states for each field
const [filterUsername, setFilterUsername] = useState("");
const [filterEmail, setFilterEmail] = useState("");
const [filterFirstName, setFilterFirstName] = useState("");
const [filterLastName, setFilterLastName] = useState("");
const [selectedRoles, setSelectedRoles] = useState([]);
const [selectedAccountTypes, setSelectedAccountTypes] = useState([]);
const [selectedActive, setSelectedActive] = useState([]);
const [selectedEmailVerified, setSelectedEmailVerified] = useState([]); // new filter state
const [filterCity, setFilterCity] = useState("");
const [filterPSC, setFilterPSC] = useState("");
const [showModal, setShowModal] = useState(false);
const [modalType, setModalType] = useState('view'); // 'view', 'edit'
const [selectedUser, setSelectedUser] = useState(null);
// Add more fields to formData for editing
const [formData, setFormData] = useState({
username: "",
email: "",
first_name: "",
last_name: "",
role: "",
account_type: "",
email_verified: false,
phone_number: "",
city: "",
street: "",
PSC: "",
bank_account: "",
ICO: "",
RC: "",
GDPR: false,
is_active: true,
var_symbol: "", // <-- add this line
});
const [error, setError] = useState(null);
const [submitting, setSubmitting] = useState(false);
const [roleDropdownOptions, setRoleDropdownOptions] = useState([]);
const [accountTypeDropdownOptions, setAccountTypeDropdownOptions] = useState([]);
// Fetch users
useEffect(() => {
const fetchData = async () => {
try {
console.log("Fetching users...");
const data = await userAPI.getUsers();
// Defensive: check if response is array, otherwise log error and set empty array
if (Array.isArray(data)) {
console.log("Fetched users:", data);
setUsers(data);
} else if (data && Array.isArray(data.results)) {
// DRF pagination: { count, next, previous, results }
console.log("Fetched users (paginated):", data.results);
setUsers(data.results);
} else {
console.error("Fetched users is not an array:", data);
setUsers([]);
}
} catch (err) {
console.error("Error fetching users:", err);
setUsers([]);
} finally {
setFetching(false);
}
};
fetchData();
}, []);
useEffect(() => {
// Načti možnosti pro role
fetchEnumFromSchemaJson("/api/account/users/", "post", "role")
.then((choices) => setRoleDropdownOptions(choices))
.catch(() => setRoleDropdownOptions([
{ value: "admin", label: "Administrátor" },
{ value: "seller", label: "Prodejce" },
{ value: "squareManager", label: "Správce tržiště" },
{ value: "cityClerk", label: "Úředník" },
{ value: "checker", label: "Kontrolor" },
]));
// Načti možnosti pro typ účtu
fetchEnumFromSchemaJson("/api/account/users/", "post", "account_type")
.then((choices) => setAccountTypeDropdownOptions(choices))
.catch(() => setAccountTypeDropdownOptions([
{ value: "company", label: "Firma" },
{ value: "individual", label: "Fyzická osoba" },
]));
}, []);
// Role/group options
const roleOptions = useMemo(() => {
if (!Array.isArray(users)) return [];
const allRoles = users.map(u => u.role).filter(Boolean);
return [...new Set(allRoles)];
}, [users]);
const accountTypeOptions = useMemo(() => {
if (!Array.isArray(users)) return [];
const allTypes = users.map(u => u.account_type).filter(Boolean);
return [...new Set(allTypes)];
}, [users]);
// Filtering
const filteredUsers = useMemo(() => {
let data = Array.isArray(users) ? users : [];
if (filterUsername) {
const q = filterUsername.toLowerCase();
data = data.filter(u => u.username?.toLowerCase().includes(q));
}
if (filterEmail) {
const q = filterEmail.toLowerCase();
data = data.filter(u => u.email?.toLowerCase().includes(q));
}
if (filterFirstName) {
const q = filterFirstName.toLowerCase();
data = data.filter(u => u.first_name?.toLowerCase().includes(q));
}
if (filterLastName) {
const q = filterLastName.toLowerCase();
data = data.filter(u => u.last_name?.toLowerCase().includes(q));
}
if (selectedRoles.length > 0) {
data = data.filter(u => selectedRoles.includes(u.role));
}
if (selectedAccountTypes.length > 0) {
data = data.filter(u => selectedAccountTypes.includes(u.account_type));
}
if (selectedActive.length > 0) {
data = data.filter(u =>
selectedActive.includes(u.is_active ? "true" : "false")
);
}
if (selectedEmailVerified.length > 0) {
data = data.filter(u =>
selectedEmailVerified.includes(u.email_verified ? "true" : "false")
);
}
if (filterCity) {
const q = filterCity.toLowerCase();
data = data.filter(u => u.city?.toLowerCase().includes(q));
}
if (filterPSC) {
const q = filterPSC.toLowerCase();
data = data.filter(u => u.PSC?.toLowerCase().includes(q));
}
return data;
}, [users, filterUsername, filterEmail, filterFirstName, filterLastName, selectedRoles, selectedAccountTypes, selectedActive, selectedEmailVerified, filterCity, filterPSC]);
// Handlers
const handleShowUser = (user) => {
console.log("Show user:", user);
setSelectedUser(user);
setModalType('view');
setShowModal(true);
};
const handleEditUser = (user) => {
console.log("Edit user:", user);
setSelectedUser(user);
setFormData({
username: user.username || "",
email: user.email || "",
first_name: user.first_name || "",
last_name: user.last_name || "",
role: user.role || "",
account_type: user.account_type || "",
email_verified: user.email_verified || false,
phone_number: user.phone_number || "",
city: user.city || "",
street: user.street || "",
PSC: user.PSC || "",
bank_account: user.bank_account || "",
ICO: user.ICO || "",
RC: user.RC || "",
GDPR: user.GDPR || false,
is_active: user.is_active ?? true,
var_symbol: user.var_symbol || "",
});
setModalType('edit');
setShowModal(true);
setError(null);
};
const handleDeleteUser = async (user) => {
console.log("Delete user:", user);
if (window.confirm(`Opravdu smazat uživatele: ${user.username}?`)) {
await userAPI.deleteUser(user.id);
const data = await userAPI.getUsers();
setUsers(data);
}
};
const handleChange = (e) => {
const { name, value, type, checked } = e.target;
console.log("Form change:", name, type === "checkbox" ? checked : value);
setFormData((old) => ({
...old,
[name]: type === "checkbox" ? checked : value,
}));
};
const handleGroupsChange = (groups) => {
console.log("Groups changed:", groups);
setFormData((old) => ({ ...old, groups }));
};
const handleEditModalSubmit = async (e) => {
e.preventDefault();
setError(null);
setSubmitting(true);
console.log("Submitting edit:", formData);
try {
await userAPI.updateUser(selectedUser.id, {
...formData,
account_type: formData.account_type,
var_symbol: formData.var_symbol, // <-- ensure this is sent
});
setShowModal(false);
const data = await userAPI.getUsers();
setUsers(data);
} catch (err) {
const apiErrors = err.response?.data;
if (typeof apiErrors === "object") {
const messages = Object.entries(apiErrors)
.map(([key, value]) => `${key}: ${Array.isArray(value) ? value.join(", ") : value}`)
.join("\n");
setError("Chyba při ukládání:\n" + messages);
console.log("API error:", apiErrors);
} else {
setError("Chyba při ukládání: " + (err.message || "Neznámá chyba"));
console.log("Unknown error:", err);
}
} finally {
setSubmitting(false);
}
};
// Table columns
const columns = [
{ accessor: "id",
title: "#",
sortable: true,
width: "2%" },
{
accessor: "username",
title: "Uživatelské jméno",
sortable: true,
width: "7%",
filter: (
<TextInput
label="Filtrovat username"
placeholder="Zadej uživatelské jméno"
leftSection={<IconSearch size={16} />}
rightSection={
<ActionIcon size="sm" variant="transparent" c="dimmed" onClick={() => setFilterUsername("")}>
<IconX size={14} />
</ActionIcon>
}
value={filterUsername}
onChange={(e) => setFilterUsername(e.currentTarget.value)}
/>
),
filtering: !!filterUsername,
},
{
accessor: "email",
title: "Email",
sortable: true,
width: "7%",
filter: (
<TextInput
label="Filtrovat email"
placeholder="Zadej email"
leftSection={<IconSearch size={16} />}
rightSection={
<ActionIcon size="sm" variant="transparent" c="dimmed" onClick={() => setFilterEmail("")}>
<IconX size={14} />
</ActionIcon>
}
value={filterEmail}
onChange={(e) => setFilterEmail(e.currentTarget.value)}
/>
),
filtering: !!filterEmail,
},
{
accessor: "first_name",
title: "Jméno",
sortable: true,
width: "5%",
filter: (
<TextInput
label="Filtrovat jméno"
placeholder="Zadej jméno"
leftSection={<IconSearch size={16} />}
rightSection={
<ActionIcon size="sm" variant="transparent" c="dimmed" onClick={() => setFilterFirstName("")}>
<IconX size={14} />
</ActionIcon>
}
value={filterFirstName}
onChange={(e) => setFilterFirstName(e.currentTarget.value)}
/>
),
filtering: !!filterFirstName,
},
{
accessor: "last_name",
title: "Příjmení",
sortable: true,
width: "5%",
filter: (
<TextInput
label="Filtrovat příjmení"
placeholder="Zadej příjmení"
leftSection={<IconSearch size={16} />}
rightSection={
<ActionIcon size="sm" variant="transparent" c="dimmed" onClick={() => setFilterLastName("")}>
<IconX size={14} />
</ActionIcon>
}
value={filterLastName}
onChange={(e) => setFilterLastName(e.currentTarget.value)}
/>
),
filtering: !!filterLastName,
},
{
accessor: "role",
title: "Role",
width: "7%",
render: (row) =>
row.role ? (
<Badge color="blue" variant="light">
{
{
"admin": "Administrátor",
"seller": "Prodejce",
"squareManager": "Správce tržiště",
"cityClerk": "Úředník",
"checker": "Kontrolor",
}[row.role] || row.role
}
</Badge>
) : (
<Text c="dimmed" fs="italic">Žádná</Text>
),
filter: (() => {
const toggle = (val) => {
setSelectedRoles(prev => prev.includes(val) ? prev.filter(v => v !== val) : [...prev, val]);
};
const roleBadges = roleOptions.map(role => {
const label = {
"admin": "Administrátor",
"seller": "Prodejce",
"squareManager": "Správce tržiště",
"cityClerk": "Úředník",
"checker": "Kontrolor",
}[role] || role;
const active = selectedRoles.includes(role);
return (
<Badge
key={role}
color={active ? 'blue' : 'gray'}
variant={active ? 'filled' : 'outline'}
style={{ cursor: 'pointer', userSelect: 'none' }}
onClick={() => toggle(role)}
aria-pressed={active}
role="button"
>
{label}
</Badge>
);
});
return (
<Stack gap={4} style={{ minWidth: 170 }}>
<Text fw={500} size="xs" c="dimmed">Filtrovat role</Text>
<Group gap={6} wrap="wrap">{roleBadges}</Group>
{selectedRoles.length > 0 && (
<Button size="xs" variant="light" onClick={() => setSelectedRoles([])}>Reset</Button>
)}
</Stack>
);
})(),
filtering: selectedRoles.length > 0,
},
{
accessor: "account_type",
title: "Typ účtu",
width: "7%",
render: (row) =>
row.account_type ? (
<Badge color="gray" variant="light">
{
accountTypeDropdownOptions.find(opt => opt.value === row.account_type)?.label || row.account_type
}
</Badge>
) : (
<Text c="dimmed" fs="italic"></Text>
),
sortable: true,
filter: (() => {
const toggle = (val) => {
setSelectedAccountTypes(prev => prev.includes(val) ? prev.filter(v => v !== val) : [...prev, val]);
};
return (
<Stack gap={4} style={{ minWidth: 170 }}>
<Text fw={500} size="xs" c="dimmed">Filtrovat typ účtu</Text>
<Group gap={6} wrap="wrap">
{accountTypeDropdownOptions.map(opt => {
const active = selectedAccountTypes.includes(opt.value);
return (
<Badge
key={opt.value}
color={active ? 'grape' : 'gray'}
variant={active ? 'filled' : 'outline'}
style={{ cursor:'pointer' }}
onClick={() => toggle(opt.value)}
aria-pressed={active}
role='button'
>
{opt.label}
</Badge>
);
})}
</Group>
{selectedAccountTypes.length > 0 && (
<Button size="xs" variant="light" onClick={() => setSelectedAccountTypes([])}>Reset</Button>
)}
</Stack>
);
})(),
filtering: selectedAccountTypes.length > 0,
},
{
accessor: "email_verified",
title: "E-mail ověřen",
width: "4%",
render: (row) =>
row.email_verified ? (
<Badge color="green" variant="light">Ano</Badge>
) : (
<Badge color="red" variant="light">Ne</Badge>
),
sortable: true,
filter: (() => {
const toggle = (val) => {
setSelectedEmailVerified(prev => prev.includes(val) ? prev.filter(v => v !== val) : [...prev, val]);
};
const options = [
{ value: 'true', label: 'Ano', color: 'green' },
{ value: 'false', label: 'Ne', color: 'red' }
];
return (
<Stack gap={4} style={{ minWidth: 140 }}>
<Text fw={500} size="xs" c="dimmed">Ověření</Text>
<Group gap={6} wrap="wrap">
{options.map(opt => {
const active = selectedEmailVerified.includes(opt.value);
return (
<Badge
key={opt.value}
color={opt.color}
variant={active ? 'filled' : 'outline'}
style={{ cursor:'pointer' }}
onClick={() => toggle(opt.value)}
aria-pressed={active}
role='button'
>
{opt.label}
</Badge>
);
})}
</Group>
{selectedEmailVerified.length > 0 && (
<Button size="xs" variant="light" onClick={() => setSelectedEmailVerified([])}>Reset</Button>
)}
</Stack>
);
})(),
filtering: selectedEmailVerified.length > 0,
},
{
accessor: "is_active",
title: "Aktivní",
width: "4%",
render: (row) => row.is_active ? (
<Badge color="green" variant="light">Ano</Badge>
) : (
<Badge color="red" variant="light">Ne</Badge>
),
sortable: true,
filter: (() => {
const toggle = (val) => {
setSelectedActive(prev => prev.includes(val) ? prev.filter(v => v !== val) : [...prev, val]);
};
const options = [
{ value: 'true', label: 'Ano', color: 'green' },
{ value: 'false', label: 'Ne', color: 'red' }
];
return (
<Stack gap={4} style={{ minWidth: 140 }}>
<Text fw={500} size="xs" c="dimmed">Aktivita</Text>
<Group gap={6} wrap="wrap">
{options.map(opt => {
const active = selectedActive.includes(opt.value);
return (
<Badge
key={opt.value}
color={opt.color}
variant={active ? 'filled' : 'outline'}
style={{ cursor:'pointer' }}
onClick={() => toggle(opt.value)}
aria-pressed={active}
role='button'
>
{opt.label}
</Badge>
);
})}
</Group>
{selectedActive.length > 0 && (
<Button size="xs" variant="light" onClick={() => setSelectedActive([])}>Reset</Button>
)}
</Stack>
);
})(),
filtering: selectedActive.length > 0,
},
{
accessor: "city",
title: "Město",
width: "5%",
filter: (
<TextInput
label="Filtrovat město"
placeholder="Zadej město"
leftSection={<IconSearch size={16} />}
rightSection={
<ActionIcon size="sm" variant="transparent" c="dimmed" onClick={() => setFilterCity("")}>
<IconX size={14} />
</ActionIcon>
}
value={filterCity}
onChange={(e) => setFilterCity(e.currentTarget.value)}
/>
),
filtering: !!filterCity,
},
{
accessor: "PSC",
title: "PSČ",
width: "3%",
filter: (
<TextInput
label="Filtrovat PSČ"
placeholder="Zadej PSČ"
leftSection={<IconSearch size={16} />}
rightSection={
<ActionIcon size="sm" variant="transparent" c="dimmed" onClick={() => setFilterPSC("")}>
<IconX size={14} />
</ActionIcon>
}
value={filterPSC}
onChange={(e) => setFilterPSC(e.currentTarget.value)}
/>
),
filtering: !!filterPSC,
},
{
accessor: "actions",
title: "Akce",
width: "3.5%",
render: (user) => (
<Group gap={4} wrap="nowrap">
<ActionIcon size="sm" variant="subtle" color="green" onClick={() => handleShowUser(user)}>
<IconEye size={16} />
</ActionIcon>
<ActionIcon size="sm" variant="subtle" color="blue" onClick={() => handleEditUser(user)}>
<IconEdit size={16} />
</ActionIcon>
<ActionIcon size="sm" variant="subtle" color="red" onClick={() => handleDeleteUser(user)}>
<IconTrash size={16} />
</ActionIcon>
</Group>
),
},
];
return (
<Container fluid className="p-0 d-flex flex-column" style={{ overflowX: "hidden", height: "100vh" }}>
<Row className="mx-0 flex-grow-1">
<Col xs={2} className="px-0 bg-light" style={{ minWidth: 0 }}>
<Sidebar />
</Col>
<Col xs={10} className="px-0 bg-white d-flex flex-column" style={{ minWidth: 0 }}>
<Group justify="space-between" align="center" px="md" py="sm">
<h1>
<IconReceipt2 size={30} style={{ marginRight: 10, marginTop: -4 }} />
Uživatelé
</h1>
<Button component="a" href="/manage/users/create" leftSection={<IconPlus size={16} />}>Vytvořit uživatele</Button>
</Group>
<Table
data={filteredUsers}
columns={columns}
fetching={fetching}
withTableBorder
borderRadius="md"
highlightOnHover
verticalAlign="center"
titlePadding="4px 8px"
/>
{/* Bootstrap Modal for view */}
<Modal show={showModal && modalType === 'view'} onHide={() => setShowModal(false)} centered>
<Modal.Header closeButton>
<Modal.Title>Detail uživatele</Modal.Title>
</Modal.Header>
<Modal.Body>
{selectedUser && (
<>
<p><strong>ID:</strong> {selectedUser.id}</p>
<p><strong>Uživatelské jméno:</strong> {selectedUser.username}</p>
<p><strong>Email:</strong> {selectedUser.email || "—"}</p>
<p><strong>Jméno:</strong> {selectedUser.first_name || "—"}</p>
<p><strong>Příjmení:</strong> {selectedUser.last_name || "—"}</p>
<p><strong>Role:</strong> {(selectedUser.groups && selectedUser.groups.length > 0) ? selectedUser.groups.join(", ") : "—"}</p>
<p><strong>Aktivní:</strong> {selectedUser.is_active ? "Ano" : "Ne"}</p>
</>
)}
</Modal.Body>
<Modal.Footer>
<BootstrapButton variant="secondary" onClick={() => setShowModal(false)}>Zavřít</BootstrapButton>
<BootstrapButton variant="primary" onClick={() => { setShowModal(false); handleEditUser(selectedUser); }}>Upravit</BootstrapButton>
</Modal.Footer>
</Modal>
{/* Bootstrap Modal for edit */}
<Modal show={showModal && modalType === 'edit'} onHide={() => setShowModal(false)} centered>
<Modal.Header closeButton>
<Modal.Title>Upravit uživatele</Modal.Title>
</Modal.Header>
<Form onSubmit={handleEditModalSubmit}>
<Modal.Body>
<Form.Group className="mb-3">
<Form.Label>Uživatelské jméno</Form.Label>
<Form.Control name="username" value={formData.username} onChange={handleChange} required />
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Email</Form.Label>
<Form.Control name="email" value={formData.email} onChange={handleChange} type="email" required />
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Jméno</Form.Label>
<Form.Control name="first_name" value={formData.first_name} onChange={handleChange} />
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Příjmení</Form.Label>
<Form.Control name="last_name" value={formData.last_name} onChange={handleChange} />
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Role</Form.Label>
<Form.Select name="role" value={formData.role} onChange={handleChange}>
<option value=""></option>
{roleDropdownOptions.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</Form.Select>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Typ účtu</Form.Label>
<Form.Select name="account_type" value={formData.account_type} onChange={handleChange}>
<option value=""></option>
{accountTypeDropdownOptions.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</Form.Select>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Telefon</Form.Label>
<Form.Control name="phone_number" value={formData.phone_number} onChange={handleChange} />
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Město</Form.Label>
<Form.Control name="city" value={formData.city} onChange={handleChange} />
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Ulice</Form.Label>
<Form.Control name="street" value={formData.street} onChange={handleChange} />
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>PSČ</Form.Label>
<Form.Control name="PSC" value={formData.PSC} onChange={handleChange} />
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Bankovní účet</Form.Label>
<Form.Control name="bank_account" value={formData.bank_account} onChange={handleChange} />
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>IČO</Form.Label>
<Form.Control name="ICO" value={formData.ICO} onChange={handleChange} />
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Rodné číslo</Form.Label>
<Form.Control name="RC" value={formData.RC} onChange={handleChange} />
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Variabilní symbol</Form.Label>
<Form.Control
name="var_symbol"
value={formData.var_symbol}
onChange={handleChange}
type="number"
min="0"
max="9999999999"
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Check
type="checkbox"
id="email_verified"
name="email_verified"
label="E-mail ověřen"
checked={formData.email_verified}
onChange={handleChange}
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Check
type="checkbox"
id="GDPR"
name="GDPR"
label="GDPR souhlas"
checked={formData.GDPR}
onChange={handleChange}
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Check
type="switch"
id="is_active"
name="is_active"
label={formData.is_active ? "Aktivní" : "Neaktivní"}
checked={formData.is_active}
onChange={e => setFormData(old => ({ ...old, is_active: e.target.checked }))}
/>
</Form.Group>
{error && <Alert variant="danger">{error}</Alert>}
</Modal.Body>
<Modal.Footer>
<BootstrapButton variant="secondary" onClick={() => setShowModal(false)}>Zrušit</BootstrapButton>
<BootstrapButton type="submit" variant="primary" disabled={submitting}>Uložit změny</BootstrapButton>
</Modal.Footer>
</Form>
</Modal>
</Col>
</Row>
</Container>
);
}
export default Users;

View File

@@ -0,0 +1,6 @@
vytváření akce má vlastní formulář
náměstí používa squaredesigner.jsx
rezervace používa košík kde admin jenom zadá uživatele v horní části formuláře které vidí jenom on

View File

@@ -0,0 +1,273 @@
import React, { useState, useRef } from "react";
import { useNavigate } from "react-router-dom";
import squareAPI from "../../../api/model/square";
import { Container, Row, Col, Form, Button, Alert, Modal } from "react-bootstrap";
import DynamicGrid, { DEFAULT_CONFIG } from "../../../components/DynamicGrid";
export default function SquareDesigner() {
const navigate = useNavigate();
const [step, setStep] = useState(1);
const [image, setImage] = useState(null);
const [imageUrl, setImageUrl] = useState("");
const [formData, setFormData] = useState({
name: "",
description: "",
street: "",
city: "",
psc: "",
width: 40,
height: 30,
cell_area: 4, // m2, default
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [success, setSuccess] = useState(false);
const [showSuccessModal, setShowSuccessModal] = useState(false);
const [imageAspect, setImageAspect] = useState(4/3); // default aspect ratio
const [error, setError] = useState(null);
const fileInputRef = useRef();
// Calculate grid size from width, height, and cell_area
const cellArea = Math.max(1, Math.floor(Number(formData.cell_area) || 1));
// cell is always square in m², so side = sqrt(cellArea)
const cellSide = Math.sqrt(cellArea);
const safeWidth = Math.max(1, Number(formData.width) || 1);
const safeHeight = Math.max(1, Number(formData.height) || 1);
let grid_cols = Math.max(1, Math.round(safeWidth / cellSide));
let grid_rows = Math.max(1, Math.round(safeHeight / cellSide));
// Prevent NaN or Infinity
if (!isFinite(grid_cols) || grid_cols < 1) grid_cols = 1;
if (!isFinite(grid_rows) || grid_rows < 1) grid_rows = 1;
const cellWidth = safeWidth / grid_cols;
const cellHeight = safeHeight / grid_rows;
// Demo: select N cells (for preview only)
const [selectedCells, setSelectedCells] = useState([]);
const handleChange = (e) => {
const { name, value } = e.target;
setFormData((old) => ({ ...old, [name]: value }));
};
const handleImageChange = (e) => {
const file = e.target.files[0];
if (file) {
setImage(file);
const url = URL.createObjectURL(file);
setImageUrl(url);
// Get image aspect ratio
const img = new window.Image();
img.onload = () => {
const aspect = img.width / img.height;
setImageAspect(aspect);
// Adjust width/height to match aspect
setFormData((old) => ({
...old,
width: Math.sqrt((old.cell_area || 1) * aspect),
height: Math.sqrt((old.cell_area || 1) / aspect),
}));
};
img.src = url;
setStep(2);
}
};
// Only allow width/height to be changed together (scaling)
const handleScale = (delta) => {
setFormData((old) => {
const scale = Math.max(0.1, 1 + delta);
const newWidth = old.width * scale;
const newHeight = newWidth / imageAspect;
return {
...old,
width: newWidth,
height: newHeight,
area: newWidth * newHeight,
};
});
};
// When user sets area, recalc width/height
const handleAreaChange = (e) => {
const area = Number(e.target.value) || 1;
setFormData((old) => ({
...old,
area,
width: Math.sqrt(area * imageAspect),
height: Math.sqrt(area / imageAspect),
}));
};
const handleImageRemove = () => {
setImage(null);
setImageUrl("");
setStep(1);
setSelectedCells([]);
if (fileInputRef.current) fileInputRef.current.value = "";
};
return (
<Container className="py-4">
<h2>Návrh náměstí / Square Designer</h2>
{step === 1 && (
<Row className="mb-4">
<Col md={6}>
<Form.Group className="mb-3">
<Form.Label>Nejprve nahrajte obrázek náměstí (doporučeno z ptačí perspektivy)</Form.Label>
<Form.Control type="file" accept="image/*" onChange={handleImageChange} ref={fileInputRef} />
</Form.Group>
</Col>
</Row>
)}
{step === 2 && (
<>
<Row className="mb-4">
<Col md={7}>
<h5>Editor mapy náměstí</h5>
<div style={{
position: "relative",
width: "100%",
maxWidth: 600,
aspectRatio: safeWidth / safeHeight || 1,
backgroundImage: imageUrl ? `url(${imageUrl})` : undefined, // only use uploaded image
backgroundSize: "contain",
backgroundRepeat: "no-repeat",
backgroundPosition: "center",
backgroundColor: "#f8f9fa",
border: "2px solid #007bff",
borderRadius: 8,
overflow: "hidden",
marginBottom: 24
}}>
{grid_cols > 0 && grid_rows > 0 && isFinite(grid_cols) && isFinite(grid_rows) && (
<DynamicGrid
config={{
cols: grid_cols,
rows: grid_rows,
cellSize: 24,
gridColor: "#007bff",
gridThickness: 2,
}}
reservations={[]}
static={true}
backgroundImage={imageUrl}
/>
)}
</div>
<div className="mt-2 mb-4">
<div>Každá buňka: <b>{cellWidth.toFixed(2)}m × {cellHeight.toFixed(2)}m</b> ({cellArea} )</div>
<div>Vybráno buněk: <b>{selectedCells.length}</b></div>
<div>Počet řádků: <b>{grid_rows}</b> | Počet sloupců: <b>{grid_cols}</b></div>
</div>
</Col>
<Col md={5}>
<Form>
<Form.Group className="mb-3">
<Form.Label>Šířka náměstí (m)</Form.Label>
<Form.Control type="number" name="width" value={formData.width} onChange={handleChange} min={1} step={1} />
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Výška náměstí (m)</Form.Label>
<Form.Control type="number" name="height" value={formData.height} onChange={handleChange} min={1} step={1} />
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Velikost jedné buňky (, pouze celé číslo)</Form.Label>
<Form.Control type="number" name="cell_area" value={formData.cell_area} onChange={handleChange} min={1} step={1} />
</Form.Group>
</Form>
</Col>
</Row>
<Row>
<Col md={8} lg={6} xl={5}>
<Form>
<Form.Group className="mb-3">
<Form.Label>Název náměstí</Form.Label>
<Form.Control name="name" value={formData.name} onChange={handleChange} required />
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Popis</Form.Label>
<Form.Control as="textarea" name="description" value={formData.description} onChange={handleChange} rows={2} />
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Ulice</Form.Label>
<Form.Control name="street" value={formData.street} onChange={handleChange} />
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Město</Form.Label>
<Form.Control name="city" value={formData.city} onChange={handleChange} />
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>PSČ</Form.Label>
<Form.Control name="psc" value={formData.psc} onChange={handleChange} type="number" min={10000} max={99999} />
</Form.Group>
<Button
variant="primary"
className="mt-2"
onClick={async (e) => {
e.preventDefault();
setError(null);
setSuccess(false);
setIsSubmitting(true);
try {
const data = new FormData();
data.append("name", formData.name);
data.append("description", formData.description);
data.append("street", formData.street);
data.append("city", formData.city);
data.append("psc", formData.psc);
data.append("width", formData.width);
data.append("height", formData.height);
data.append("grid_rows", grid_rows);
data.append("grid_cols", grid_cols);
data.append("cellsize", cellArea);
if (image) data.append("image", image); // <-- send image file
await squareAPI.createSquare(data); // <-- send FormData with image
setSuccess(true);
setShowSuccessModal(true);
setTimeout(() => {
setShowSuccessModal(false);
navigate("/manage/squares");
}, 1400);
} catch (err) {
setError("Chyba při vytváření náměstí. Zkontrolujte data.");
} finally {
setIsSubmitting(false);
}
}}
disabled={isSubmitting || !formData.name || !formData.city || !formData.psc || !formData.width || !formData.height}
>
{isSubmitting ? "Ukládám..." : "Vytvořit nové náměstí"}
</Button>
{success && <Alert variant="success" className="mt-3">Náměstí bylo úspěšně vytvořeno!</Alert>}
{error && <Alert variant="danger" className="mt-3">{error}</Alert>}
<Button variant="outline-danger" onClick={handleImageRemove} className="mt-3 ms-2">Změnit obrázek</Button>
</Form>
</Col>
</Row>
</>
)}
{error && <Alert variant="danger">{error}</Alert>}
<Button variant="secondary" href="/manage/squares">Zpět na seznam náměstí</Button>
{/* Success Modal with animated checkmark */}
<Modal show={showSuccessModal} centered backdrop="static" keyboard={false} contentClassName="text-center p-4">
<div style={{ fontSize: 80, color: '#28a745', marginBottom: 16, animation: 'pop 0.5s cubic-bezier(.68,-0.55,.27,1.55)' }}>
<svg width="80" height="80" viewBox="0 0 80 80">
<circle cx="40" cy="40" r="38" fill="#eafaf1" stroke="#28a745" strokeWidth="4" />
<polyline points="24,44 36,56 56,28" fill="none" stroke="#28a745" strokeWidth="6" strokeLinecap="round" strokeLinejoin="round">
<animate attributeName="points" dur="0.5s" values="24,44 36,56 36,56;24,44 36,56 56,28" keyTimes="0;1" fill="freeze" />
</polyline>
</svg>
</div>
<h4 className="mb-0">Náměstí bylo úspěšně vytvořeno!</h4>
</Modal>
<style>{`
@keyframes pop {
0% { transform: scale(0.7); opacity: 0; }
80% { transform: scale(1.1); opacity: 1; }
100% { transform: scale(1); }
}
`}</style>
</Container>
);
}

View File

@@ -0,0 +1,244 @@
import React, { useState, useEffect } from "react";
import { Container, Row, Col, Form, Button, Table, Alert } from "react-bootstrap";
import { useNavigate } from "react-router-dom";
import eventAPI from "../../../api/model/event";
import squareAPI from "../../../api/model/square";
export default function CreateEvent({ onCreated }) {
const [form, setForm] = useState({ ...eventAPI.defaultEvent });
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [errorDetail, setErrorDetail] = useState(null);
const [squares, setSquares] = useState([]);
const [squaresLoading, setSquaresLoading] = useState(false);
const [confirmed, setConfirmed] = useState(false);
const navigate = useNavigate();
useEffect(() => {
setSquaresLoading(true);
squareAPI.getSquares()
.then(data => setSquares(data))
.catch(() => setSquares([]))
.finally(() => setSquaresLoading(false));
}, []);
const handleChange = e => {
const { name, value } = e.target;
setForm(f => ({ ...f, [name]: value }));
};
const handleSquareSelect = square => {
setForm(f => ({ ...f, square_id: square.id }));
};
const handleSubmit = async e => {
e.preventDefault();
setLoading(true);
setError("");
setErrorDetail(null);
// Require square selection
if (!form.square_id) {
setError("Vyberte náměstí (plocha) pro událost.");
setLoading(false);
return;
}
try {
const response = await eventAPI.createEvent(form);
setForm({ ...eventAPI.defaultEvent, square: null });
setConfirmed(true);
if (onCreated) onCreated();
} catch (err) {
// Show error message in UI
let msg = "Chyba při vytváření události.";
if (err && err.response && err.response.data) {
if (typeof err.response.data === "string") {
msg = `Chyba: ${err.response.data}`;
} else if (err.response.data.detail) {
msg = `Chyba: ${err.response.data.detail}`;
} else {
// Validation errors: show all fields
msg = Object.entries(err.response.data)
.map(([field, val]) => `${field}: ${Array.isArray(val) ? val.join(", ") : val}`)
.join("\n");
}
setErrorDetail(err.response.data);
} else {
setErrorDetail(err);
}
setError(msg);
} finally {
setLoading(false);
}
};
const handleConfirmOk = () => {
setConfirmed(false);
window.location.href = "/manage/events";
};
return (
<Container className="mt-4">
{confirmed ? (
<Alert variant="success">
Událost byla úspěšně vytvořena.
<div className="mt-3">
<Button variant="primary" onClick={handleConfirmOk}>
OK
</Button>
</div>
</Alert>
) : (
<Form onSubmit={handleSubmit}>
{/* Row for Název události and Popis */}
<Row>
<Col md={6} className="mb-3">
<Form.Group controlId="eventName">
<Form.Label>Název události</Form.Label>
<Form.Control
name="name"
value={form.name || ""}
onChange={handleChange}
required
/>
</Form.Group>
</Col>
</Row>
<Row>
<Col md={6} className="mb-3">
<Form.Group controlId="eventDescription">
<Form.Label>Popis</Form.Label>
<Form.Control
as="textarea"
name="description"
value={form.description || ""}
onChange={handleChange}
rows={1}
/>
</Form.Group>
</Col>
</Row>
{/* Row for dates */}
<Row>
<Col md={6} className="mb-3">
<Form.Group controlId="eventStart">
<Form.Label>Datum začátku</Form.Label>
<Form.Control
type="date"
name="start"
value={form.start || ""}
onChange={handleChange}
required
/>
</Form.Group>
</Col>
<Col md={6} className="mb-3">
<Form.Group controlId="eventEnd">
<Form.Label>Datum konce</Form.Label>
<Form.Control
type="date"
name="end"
value={form.end || ""}
onChange={handleChange}
required
/>
</Form.Group>
</Col>
</Row>
{/* Row for Výběr náměstí */}
<Row>
<Col md={12} className="mb-3">
<Form.Group controlId="eventSquare">
<Form.Label>Výběr náměstí</Form.Label>
<div style={{ maxHeight: 180, overflowY: "auto" }}>
<Table size="sm" bordered>
<thead>
<tr>
<th>Náměstí</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{squaresLoading ? (
<tr>
<td colSpan={2}>Načítání...</td>
</tr>
) : squares.length === 0 ? (
<tr>
<td colSpan={2}>Žádné plochy</td>
</tr>
) : (
squares.map(sq => (
<tr key={sq.id}>
<td>{sq.name}</td>
<td>
<Button
variant={form.square_id === sq.id ? "success" : "outline-primary"}
size="sm"
type="button"
onClick={() => handleSquareSelect(sq)}
>
{form.square_id === sq.id ? "Vybráno" : "Vybrat"}
</Button>
</td>
</tr>
))
)}
</tbody>
</Table>
</div>
{form.square_id && (
<div className="mt-1 text-success">
Vybraná plocha ID: {form.square_id}
</div>
)}
</Form.Group>
</Col>
</Row>
{/* Row for Cena za m² */}
<Row>
<Col md={4} className="mb-3">
<Form.Group controlId="eventPrice">
<Form.Label>Cena za </Form.Label>
<Form.Control
type="number"
name="price_per_m2"
value={form.price_per_m2 || ""}
onChange={handleChange}
required
min={0}
/>
</Form.Group>
</Col>
</Row>
{/* Error and submit */}
{error && (
<Alert variant="danger">
<div>{error}</div>
{errorDetail && (
<details style={{ marginTop: 8 }}>
<summary>Detail chyby</summary>
<pre style={{ whiteSpace: "pre-wrap", fontSize: 12 }}>
{typeof errorDetail === "object"
? JSON.stringify(errorDetail, null, 2)
: String(errorDetail)}
</pre>
</details>
)}
</Alert>
)}
<Row>
<Col md={12} className="mb-3">
<Button
variant="primary"
type="submit"
disabled={loading || !form.square_id}
>
{loading ? "Ukládání..." : "Vytvořit událost"}
</Button>
</Col>
</Row>
</Form>
)}
</Container>
);
}

View File

@@ -0,0 +1,93 @@
import React, { useState } from "react";
import { Container, Row, Col, Form, Alert, Button as BootstrapButton } from "react-bootstrap";
import Sidebar from "../../../components/Sidebar";
import { Group } from "@mantine/core";
import { useNavigate } from "react-router-dom";
import productAPI from "../../../api/model/product";
function CreateProduct() {
const navigate = useNavigate();
const [formData, setFormData] = useState({
name: "",
description: "",
price: "",
is_active: true,
});
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState(null);
const handleChange = (e) => {
const { name, value, type, checked } = e.target;
setFormData(prev => ({ ...prev, [name]: type === "checkbox" ? checked : value }));
};
const handleSubmit = async (e) => {
e.preventDefault();
setSubmitting(true);
setError(null);
try {
await productAPI.createProduct({
name: formData.name,
description: formData.description,
price: formData.price === "" ? null : Number(formData.price),
is_active: !!formData.is_active,
});
navigate("/manage/products");
} catch (err) {
const apiErrors = err.response?.data;
if (typeof apiErrors === "object") {
const messages = Object.entries(apiErrors)
.map(([k, v]) => `${k}: ${Array.isArray(v) ? v.join(", ") : v}`)
.join("\n");
setError(messages);
} else {
setError(err.message || "Neznámá chyba");
}
} finally {
setSubmitting(false);
}
};
return (
<Container fluid className="p-0 d-flex flex-column" style={{ overflowX: "hidden", height: "100vh" }}>
<Row className="mx-0 flex-grow-1">
<Col xs={2} className="px-0 bg-light" style={{ minWidth: 0 }}>
<Sidebar />
</Col>
<Col xs={10} className="px-0 bg-white d-flex flex-column" style={{ minWidth: 0 }}>
<div className="p-3">
<h1>Vytvořit produkt</h1>
<Form onSubmit={handleSubmit}>
<Form.Group className="mb-3">
<Form.Label>Název</Form.Label>
<Form.Control name="name" value={formData.name} onChange={handleChange} required />
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Popis</Form.Label>
<Form.Control as="textarea" rows={4} name="description" value={formData.description} onChange={handleChange} />
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Cena ()</Form.Label>
<Form.Control type="number" name="price" min="0" step="0.01" value={formData.price} onChange={handleChange} />
</Form.Group>
<Form.Group className="mb-3">
<Form.Check type="checkbox" name="is_active" label="Aktivní" checked={!!formData.is_active} onChange={handleChange} />
</Form.Group>
{error && <Alert variant="danger">{error}</Alert>}
<Group mt="md" gap="sm">
<BootstrapButton variant="outline" onClick={() => navigate("/manager/products")}>
Zpět
</BootstrapButton>
<BootstrapButton type="submit" variant="primary" disabled={submitting}>
Vytvořit
</BootstrapButton>
</Group>
</Form>
</div>
</Col>
</Row>
</Container>
);
}
export default CreateProduct;

View File

@@ -0,0 +1,283 @@
import React, { useState, useEffect } from "react";
import userAPI from "../../../api/model/user"; // adjust import if needed
import Form from 'react-bootstrap/Form';
import { fetchEnumFromSchemaJson } from "../../../api/get_chocies";
const initialForm = {
first_name: "",
last_name: "",
email: "",
phone_number: "",
account_type: "",
role: "",
password: "",
city: "",
street: "",
PSC: "",
bank_account: "",
RC: "",
ICO: "",
GDPR: false,
};
export default function CreateUser() {
const [form, setForm] = useState(initialForm);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [success, setSuccess] = useState(false);
const [roleChoices, setRoleChoices] = useState([]);
const [accountTypeChoices, setAccountTypeChoices] = useState([]);
useEffect(() => {
// Fetch choices from OpenAPI schema for role and account_type
fetchEnumFromSchemaJson("/api/account/users/", "post", "role")
.then((choices) => setRoleChoices(choices))
.catch(() => setRoleChoices([
{ value: "admin", label: "Administrátor" },
{ value: "seller", label: "Prodejce" },
{ value: "squareManager", label: "Správce tržiště" },
{ value: "cityClerk", label: "Úředník" },
{ value: "checker", label: "Kontrolor" },
]));
fetchEnumFromSchemaJson("/api/account/users/", "post", "account_type")
.then((choices) => setAccountTypeChoices(choices))
.catch(() => setAccountTypeChoices([
{ value: "company", label: "Firma" },
{ value: "individual", label: "Fyzická osoba" },
]));
}, []);
const handleChange = (e) => {
const { name, value, type, checked } = e.target;
setForm((prev) => ({
...prev,
[name]: type === "checkbox" ? checked : value,
}));
};
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
setError(null);
setSuccess(false);
try {
await userAPI.createUser(form);
setSuccess(true);
setForm(initialForm);
} catch (err) {
setError(err.message || "Chyba při vytváření uživatele.");
} finally {
setLoading(false);
}
};
return (
<div className="container mt-5">
<div className="row justify-content-center">
<div className="col-md-8 col-lg-6">
<div className="card shadow">
<div className="card-header bg-primary text-white">
<h3 className="mb-0">Registrace nového uživatele</h3>
</div>
<form className="card-body" onSubmit={handleSubmit}>
<div className="mb-3">
<label className="form-label">Jméno</label>
<input
type="text"
name="first_name"
className="form-control"
value={form.first_name}
onChange={handleChange}
required
/>
</div>
<div className="mb-3">
<label className="form-label">Příjmení</label>
<input
type="text"
name="last_name"
className="form-control"
value={form.last_name}
onChange={handleChange}
required
/>
</div>
<div className="mb-3">
<label className="form-label">Email</label>
<input
type="email"
name="email"
className="form-control"
value={form.email}
onChange={handleChange}
required
/>
</div>
<div className="mb-3">
<label className="form-label">Telefonní číslo</label>
<input
type="text"
name="phone_number"
className="form-control"
value={form.phone_number}
onChange={handleChange}
required
placeholder="+420123456789"
/>
</div>
<div className="mb-3">
<label className="form-label">Typ účtu</label>
<Form.Select
aria-label="Typ účtu"
name="account_type"
value={form.account_type}
onChange={handleChange}
required
>
<option value="">Vyberte typ účtu</option>
{accountTypeChoices.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</Form.Select>
</div>
<div className="mb-3">
<label className="form-label">Role</label>
<Form.Select
aria-label="Role"
name="role"
value={form.role || ""}
onChange={handleChange}
required
>
<option value="">Vyberte roli</option>
{roleChoices.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</Form.Select>
</div>
<div className="mb-3">
<label className="form-label">Heslo</label>
<input
type="password"
name="password"
className="form-control"
value={form.password}
onChange={handleChange}
required
minLength={8}
placeholder="Min. 8 znaků, velké/malé písmeno, číslice"
/>
</div>
<div className="mb-3">
<label className="form-label">Město</label>
<input
type="text"
name="city"
className="form-control"
value={form.city}
onChange={handleChange}
required
/>
</div>
<div className="mb-3">
<label className="form-label">Ulice</label>
<input
type="text"
name="street"
className="form-control"
value={form.street}
onChange={handleChange}
required
/>
</div>
<div className="mb-3">
<label className="form-label">PSČ</label>
<input
type="text"
name="PSC"
className="form-control"
value={form.PSC}
onChange={handleChange}
required
placeholder="12345"
/>
</div>
<div className="mb-3">
<label className="form-label">Číslo bankovního účtu</label>
<input
type="text"
name="bank_account"
className="form-control"
value={form.bank_account}
onChange={handleChange}
required
placeholder="1234567890/0100"
/>
</div>
<div className="mb-3">
<label className="form-label">Rodné číslo</label>
<input
type="text"
name="RC"
className="form-control"
value={form.RC}
onChange={handleChange}
required
placeholder="123456/7890"
/>
</div>
<div className="mb-3">
<label className="form-label">IČO</label>
<input
type="text"
name="ICO"
className="form-control"
value={form.ICO}
onChange={handleChange}
required
placeholder="12345678"
/>
</div>
<div className="form-check mb-3">
<input
className="form-check-input"
type="checkbox"
name="GDPR"
id="gdprCheck"
checked={form.GDPR}
onChange={handleChange}
required
/>
<label className="form-check-label" htmlFor="gdprCheck">
Souhlasím se zpracováním osobních údajů (GDPR)
</label>
</div>
<button
type="submit"
className="btn btn-primary w-100"
disabled={loading}
>
{loading ? (
<span>
<span className="spinner-border spinner-border-sm me-2"></span>
Ukládám...
</span>
) : (
"Vytvořit uživatele"
)}
</button>
{error && (
<div className="alert alert-danger mt-3">{error}</div>
)}
{success && (
<div className="alert alert-success mt-3">
Uživatel byl úspěšně vytvořen.
</div>
)}
</form>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,266 @@
import React, { useState, useEffect } from "react";
import { useParams } from "react-router-dom";
import { Container, Row, Col, Card, ListGroup } from "react-bootstrap";
import DynamicGrid, { DEFAULT_CONFIG } from "../../../components/DynamicGrid";
import apiEvent from "../../../api/model/event";
import apiSquare from "../../../api/model/square";
import apiMarketSlot from "../../../api/model/market_slot";
function MapEditor() {
const { eventId } = useParams();
const [gridConfig, setGridConfig] = useState(DEFAULT_CONFIG);
const [eventObject, setEventObject] = useState(null);
const [marketSlots, setMarketSlots] = useState([]);
const [reservations, setReservations] = useState([]);
const [selectedIndex, setSelectedIndex] = useState(null);
const [squareObject, setSquareObject] = useState(null);
// 🟡 Načtení eventu a slotů z databáze
useEffect(() => {
if (!eventId) return;
async function fetchSlots() {
try {
const data = await apiEvent.getEventById(eventId);
setEventObject(data);
setMarketSlots((data.market_slots || []).map((slot) => ({
...slot,
status: slot.status || "active", // <- doplní výchozí hodnotu
}))
);
const sqData = await apiSquare.getSquareById(data.square.id)
setSquareObject(sqData);
const rows = sqData.grid_rows;
const cols = sqData.grid_cols;
setGridConfig({
...DEFAULT_CONFIG,
rows,
cols,
});
// Převedení slotů na "reservations" formát
const loadedReservations = (data.market_slots || []).map((slot, index) => ({
id: slot.id,
name: slot.label || `Slot #${index + 1}`,
x: slot.x,
y: slot.y,
w: slot.width || 1,
h: slot.height || 1,
locked: slot.locked || false,
status: slot.status,
base_size: slot.base_size ?? undefined,
available_extension: slot.available_extension ?? 0,
}));
setReservations(loadedReservations);
} catch (error) {
console.error("Chyba při načítání eventu a slotů:", error);
}
}
fetchSlots();
}, [eventId]);
/*
// (Volitelně) Uložení rezervací do localStorage pokud chceš mít zálohu
useEffect(() => {
const storageKey = `reservationData_${eventId}_${gridConfig.rows}x${gridConfig.cols}`;
localStorage.setItem(storageKey, JSON.stringify(reservations));
}, [reservations, storageKey]);
*/
const exportReservations = () => {
const dataStr = JSON.stringify(reservations, null, 2);
const blob = new Blob([dataStr], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "reservations.json";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
};
const clearAll = () => {
localStorage.removeItem(storageKey);
setReservations([]);
setSelectedIndex(null);
};
{/* UKLÁDANÍ */}
const saveAndSend = async () => {
try {
console.log("Ukládám rezervace:", reservations);
// Pro každou rezervaci: update pokud je id, create pokud ne
for (const res of reservations) {
// Připrav data ve formátu API
const data = {
event: eventId,
status: res.status || "active",
base_size: res.base_size ?? (res.w || 1) * (res.h || 1),
available_extension: res.available_extension ?? 0,
x: res.x,
y: res.y,
width: res.w,
height: res.h,
price_per_m2: 0, // případně doplnit podle potřeby
};
if (res.id) {
// Update existujícího slotu
await apiMarketSlot.updateMarketSlot(res.id, data);
} else {
// Vytvoření nového slotu
const created = await apiMarketSlot.createMarketSlot(data);
// Aktualizuj ID v state, aby bylo aktuální
res.id = created.id;
}
}
alert("Rezervace byly úspěšně uloženy.");
} catch (error) {
console.error("Chyba při ukládání rezervací:", error);
alert("Chyba při ukládání rezervací, zkontrolujte konzoli.");
}
};
return (
<Container className="mt-4">
<h3>
Rezervace pro event:{" "}
{eventObject?.name || `#${eventId}`}
</h3>
{eventObject && (
<Card className="mb-4">
<Card.Body>
<h5 className="mb-2">{eventObject.name}</h5>
<p className="mb-1 text-muted">{eventObject.description}</p>
<p className="mb-0">
Náměstí:{" "}
<strong>{eventObject.square?.name || "Neznámé náměstí"}</strong>
</p>
<p className="mb-0">
Termín:{" "}
{new Date(eventObject.start).toLocaleString()} {" "}
{new Date(eventObject.end).toLocaleString()}
</p>
</Card.Body>
</Card>
)}
<Row>
<Col sm={6} md={8} className="d-flex">
<DynamicGrid
config={gridConfig}
reservations={reservations}
onReservationsChange={setReservations}
selectedIndex={selectedIndex}
onSelectedIndexChange={setSelectedIndex}
marketSlots={marketSlots}
backgroundImage={squareObject?.image}
/>
</Col>
<Col sm={6} md={4}>
<Card>
<Card.Header className="d-flex justify-content-between align-items-center">
<h5 className="mb-0">Seznam slotu</h5>
<span className="badge bg-info text-white">
{reservations.length}
</span>
</Card.Header>
{/* Make the list scrollable */}
<div style={{ maxHeight: "80vh", overflowY: "auto" }}>
<ListGroup className="list-group-flush">
{reservations.map((res, i) => (
<ListGroup.Item
key={res.id || i}
action
active={i === selectedIndex}
onClick={() => setSelectedIndex(i)}
>
<div className="d-flex justify-content-between align-items-center">
<div>
<strong>{i + 1}.</strong> {res.name}
</div>
<span className="badge bg-secondary">
{res.w}×{res.h}
</span>
</div>
<div className="text-muted mt-1">
[{res.x},{res.y}] [{res.x + res.w - 1},{res.y + res.h - 1}]
</div>
{/* Editable fields */}
<div className="mt-2">
<label className="form-label mb-1" style={{ fontSize: "0.95em" }}>
Základní velikost ():
<input
type="number"
className="form-control form-control-sm"
style={{ width: "100px", display: "inline-block", marginLeft: "8px" }}
value={res.base_size ?? ""}
min={0}
onChange={e => {
const value = e.target.value === "" ? undefined : Number(e.target.value);
setReservations(prev =>
prev.map((r, idx) =>
idx === i ? { ...r, base_size: value } : r
)
);
}}
placeholder="volitelné"
/>
</label>
</div>
<div className="mt-2">
<label className="form-label mb-1" style={{ fontSize: "0.95em" }}>
Možnost rozšíření ():
<input
type="number"
className="form-control form-control-sm"
style={{ width: "100px", display: "inline-block", marginLeft: "8px" }}
value={res.available_extension ?? ""}
min={0}
required
onChange={e => {
const value = Number(e.target.value);
setReservations(prev =>
prev.map((r, idx) =>
idx === i ? { ...r, available_extension: value } : r
)
);
}}
placeholder="povinné"
/>
</label>
</div>
</ListGroup.Item>
))}
</ListGroup>
</div>
</Card>
</Col>
</Row>
<div className="mt-3">
<button onClick={exportReservations} className="btn btn-primary me-2">
Exportovat
</button>
<button onClick={clearAll} className="btn btn-danger me-2">
Vymazat vše
</button>
<button onClick={saveAndSend} className="btn btn-success">
Uložit a odeslat
</button>
</div>
</Container>
);
}
export default MapEditor;

View File

@@ -0,0 +1,17 @@
import {Container, Button, Card, Row, Col} from "react-bootstrap";
import ConfirmEmailBar from "../../components/ConfirmEmailBar";
function Login() {
return (
<Container fluid className="flex-grow-1 login-bg py-5">
<div className="d-flex flex-column justify-content-center h-100">
<ConfirmEmailBar />
</div>
<div className="m-auto ">
<h2 className="text-center my-5 text-white fw-semibold">eTržnice</h2>
</div>
</Container>
);
}
export default Login;

View File

@@ -0,0 +1,84 @@
import RegisterCard from "../../components/RegisterCard";
import { Modal, Col, Row, Container, Button, Form, Card, ToggleButton, InputGroup } from "react-bootstrap";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faEnvelope, faMobileAlt } from "@fortawesome/free-solid-svg-icons";
function Register() {
return (
<div className="registerPortal flex-grow">
<Container>
<Row>
<Col sm={6} md={5}>
<h1>Registrace nájemníků městského obvodu Ostrava-Jih</h1>
<p>
Mokrý stín tiše stékal po svahu, zatímco{" "}
<strong>mlžné chvění</strong> vířilo nad klidnou plání pod večerní
oblohou.
</p>
<p>
Klopýtající zrnka páry mizela v houstnoucí šedi, kde{" "}
<strong>beztvaré ozvěny</strong>{" "}
tlumeně tančily pod rytmem vzdálených kapek.
</p>
<p>
Jemné šustění závanu rozléhalo se tichem. <br />
Drobné úlomky snu klouzaly po <strong>
struktuře bez cíle
</strong>. <br />
Nezřetelný obraz mizel v jemném odlesku nedořečeného rána.
</p>
<h3 className="text-white pt-3 pb-2">
Neumíte se přihlásit? Kontaktujte nás:
</h3>
<h3>
<ul className="list-unstyled">
<li className="pb-2">
<FontAwesomeIcon icon={faMobileAlt} />
<span className="pr-2">
<a href="tel:+420599430331"> 599 430 331</a>
</span>
<br />
<div className="d-sm-block d-md-inline">
{" "}
<FontAwesomeIcon icon={faEnvelope} />
<a href="mailto:jana.molnari@ovajih.cz">
{" "}
jana.molnari@ovajih.cz
</a>
</div>
</li>
<li>
<FontAwesomeIcon icon={faMobileAlt} />
<span className="pr-2">
<a href="tel:+420702003539"> 702 003 539</a>
</span>
<br />
<div className="d-sm-block d-md-inline">
<FontAwesomeIcon icon={faEnvelope} />
<a href="mailto:tereza.masarovicova@ovajih.cz">
{" "}
tereza.masarovicova@ovajih.cz
</a>
</div>
</li>
</ul>
<p></p>
</h3>
</Col>
<Col className="d-none d-md-block middle-border" md={1}></Col>
<Col className="d-none d-md-block" md={1}></Col>
<Col sm={6} md={5}>
<RegisterCard />
</Col>
</Row>
</Container>
</div>
);
}
export default Register;

8
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,8 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})