init
This commit is contained in:
3
frontend/.dockerignore
Normal file
3
frontend/.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal 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
16
frontend/Dockerfile.prod
Normal 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
0
frontend/README.md
Normal file
29
frontend/eslint.config.js
Normal file
29
frontend/eslint.config.js
Normal 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
13
frontend/index.html
Normal 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
4298
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
51
frontend/package.json
Normal file
51
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
14
frontend/postcss.config.cjs
Normal file
14
frontend/postcss.config.cjs
Normal 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
BIN
frontend/public/img/bg.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 380 KiB |
BIN
frontend/public/img/logo.png
Normal file
BIN
frontend/public/img/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
BIN
frontend/public/img/namest-1.png
Normal file
BIN
frontend/public/img/namest-1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 269 KiB |
BIN
frontend/public/img/register-bg.jpg
Normal file
BIN
frontend/public/img/register-bg.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
1
frontend/public/vite.svg
Normal file
1
frontend/public/vite.svg
Normal 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
19
frontend/src/App.css
Normal 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
155
frontend/src/App.jsx
Normal 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
254
frontend/src/api/auth.js
Normal 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 };
|
||||
41
frontend/src/api/get_chocies.js
Normal file
41
frontend/src/api/get_chocies.js
Normal 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;
|
||||
}
|
||||
}
|
||||
89
frontend/src/api/model/Settings.js
Normal file
89
frontend/src/api/model/Settings.js
Normal 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,
|
||||
};
|
||||
78
frontend/src/api/model/bin.js
Normal file
78
frontend/src/api/model/bin.js
Normal 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,
|
||||
};
|
||||
79
frontend/src/api/model/event-product.js
Normal file
79
frontend/src/api/model/event-product.js
Normal 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,
|
||||
};
|
||||
76
frontend/src/api/model/event.js
Normal file
76
frontend/src/api/model/event.js
Normal 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,
|
||||
};
|
||||
74
frontend/src/api/model/market_slot.js
Normal file
74
frontend/src/api/model/market_slot.js
Normal 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,
|
||||
};
|
||||
82
frontend/src/api/model/order.js
Normal file
82
frontend/src/api/model/order.js
Normal 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,
|
||||
};
|
||||
98
frontend/src/api/model/product.js
Normal file
98
frontend/src/api/model/product.js
Normal 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,
|
||||
};
|
||||
96
frontend/src/api/model/reservation.js
Normal file
96
frontend/src/api/model/reservation.js
Normal 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,
|
||||
};
|
||||
75
frontend/src/api/model/square.js
Normal file
75
frontend/src/api/model/square.js
Normal 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
|
||||
};
|
||||
85
frontend/src/api/model/ticket.js
Normal file
85
frontend/src/api/model/ticket.js
Normal 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,
|
||||
};
|
||||
73
frontend/src/api/model/user.js
Normal file
73
frontend/src/api/model/user.js
Normal 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;
|
||||
34
frontend/src/api/tutorialy/user.js
Normal file
34
frontend/src/api/tutorialy/user.js
Normal 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
|
||||
46
frontend/src/api/tutorialy/volání api.js
Normal file
46
frontend/src/api/tutorialy/volání api.js
Normal 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/");
|
||||
362
frontend/src/assets/json/data.json
Normal file
362
frontend/src/assets/json/data.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
]
|
||||
1
frontend/src/assets/react.svg
Normal file
1
frontend/src/assets/react.svg
Normal 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 |
114
frontend/src/components/ConfirmEmailBar.jsx
Normal file
114
frontend/src/components/ConfirmEmailBar.jsx
Normal 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;
|
||||
525
frontend/src/components/DynamicGrid.jsx
Normal file
525
frontend/src/components/DynamicGrid.jsx
Normal 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;
|
||||
155
frontend/src/components/DynamicMap.jsx
Normal file
155
frontend/src/components/DynamicMap.jsx
Normal 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;
|
||||
175
frontend/src/components/LoginCard.jsx
Normal file
175
frontend/src/components/LoginCard.jsx
Normal 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 mě
|
||||
</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;
|
||||
71
frontend/src/components/NavBar.jsx
Normal file
71
frontend/src/components/NavBar.jsx
Normal 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;
|
||||
480
frontend/src/components/RegisterCard.jsx
Normal file
480
frontend/src/components/RegisterCard.jsx
Normal 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} />
|
||||
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} />
|
||||
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} />
|
||||
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} />
|
||||
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} />
|
||||
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} />
|
||||
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} />
|
||||
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} />
|
||||
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} />
|
||||
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} />
|
||||
Čí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>RČ/IČ</Form.Label>
|
||||
<InputGroup>
|
||||
<div className="input-group-prepend">
|
||||
<InputGroup.Text className="isize">
|
||||
{isFirm ? (
|
||||
<FontAwesomeIcon icon={faBook} />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faAddressCard} />
|
||||
)}
|
||||
{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 & 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;
|
||||
0
frontend/src/components/ReportForm.jsx
Normal file
0
frontend/src/components/ReportForm.jsx
Normal file
209
frontend/src/components/Settings.jsx
Normal file
209
frontend/src/components/Settings.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
72
frontend/src/components/Sidebar.jsx
Normal file
72
frontend/src/components/Sidebar.jsx
Normal 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;
|
||||
197
frontend/src/components/Table.jsx
Normal file
197
frontend/src/components/Table.jsx
Normal 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;
|
||||
178
frontend/src/components/User-Settings.jsx
Normal file
178
frontend/src/components/User-Settings.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
135
frontend/src/components/forms/ticket.jsx
Normal file
135
frontend/src/components/forms/ticket.jsx
Normal 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;
|
||||
345
frontend/src/components/reservation/ReservationWizard.jsx
Normal file
345
frontend/src/components/reservation/ReservationWizard.jsx
Normal 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;
|
||||
88
frontend/src/components/reservation/Step1SelectSquare.jsx
Normal file
88
frontend/src/components/reservation/Step1SelectSquare.jsx
Normal 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;
|
||||
120
frontend/src/components/reservation/Step2SelectEvent.jsx
Normal file
120
frontend/src/components/reservation/Step2SelectEvent.jsx
Normal 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 m²:</strong> {ev.price_per_m2} Kč</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;
|
||||
223
frontend/src/components/reservation/Step3Map.jsx
Normal file
223
frontend/src/components/reservation/Step3Map.jsx
Normal 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} Kč</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>
|
||||
);
|
||||
}
|
||||
138
frontend/src/components/reservation/Step4Summary.jsx
Normal file
138
frontend/src/components/reservation/Step4Summary.jsx
Normal 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 m²: <strong>{selectedEvent.price_per_m2} Kč</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} m²</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 m²</td>
|
||||
<td>{pricePerM2.toFixed(2)} Kč</td>
|
||||
</tr>
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colSpan={2} className="text-end">
|
||||
<strong>Celková cena objednávky:</strong>
|
||||
</td>
|
||||
<td><strong>{totalPrice.toFixed(2)} Kč</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;
|
||||
|
||||
219
frontend/src/components/reservation/step3/Calendar.jsx
Normal file
219
frontend/src/components/reservation/step3/Calendar.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
149
frontend/src/components/reset-password/Create.jsx
Normal file
149
frontend/src/components/reset-password/Create.jsx
Normal 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;
|
||||
109
frontend/src/components/reset-password/Request.jsx
Normal file
109
frontend/src/components/reset-password/Request.jsx
Normal 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;
|
||||
161
frontend/src/components/save.txt
Normal file
161
frontend/src/components/save.txt
Normal 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>
|
||||
56
frontend/src/components/security/RequireAuthLayout.jsx
Normal file
56
frontend/src/components/security/RequireAuthLayout.jsx
Normal 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 />;
|
||||
}
|
||||
32
frontend/src/components/security/RequireRole.jsx
Normal file
32
frontend/src/components/security/RequireRole.jsx
Normal 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;
|
||||
}
|
||||
30
frontend/src/context/UserContext.jsx
Normal file
30
frontend/src/context/UserContext.jsx
Normal 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
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
18
frontend/src/main.jsx
Normal 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>
|
||||
);
|
||||
11
frontend/src/pages/Admin.jsx
Normal file
11
frontend/src/pages/Admin.jsx
Normal file
@@ -0,0 +1,11 @@
|
||||
|
||||
function Admin(){
|
||||
|
||||
return(
|
||||
<div>
|
||||
<header>Admin page</header>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Admin
|
||||
17
frontend/src/pages/HelpDesk.jsx
Normal file
17
frontend/src/pages/HelpDesk.jsx
Normal 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
674
frontend/src/pages/Home.jsx
Normal 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)} Kč`,
|
||||
},
|
||||
{ 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)} Kč`,
|
||||
},
|
||||
{
|
||||
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)} Kč</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)} Kč</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)} Kč</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;
|
||||
41
frontend/src/pages/Login.jsx
Normal file
41
frontend/src/pages/Login.jsx
Normal 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;
|
||||
45
frontend/src/pages/PasswordReset.jsx
Normal file
45
frontend/src/pages/PasswordReset.jsx
Normal 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;
|
||||
84
frontend/src/pages/PaymentPage.jsx
Normal file
84
frontend/src/pages/PaymentPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
frontend/src/pages/Reservation-cart.jsx
Normal file
21
frontend/src/pages/Reservation-cart.jsx
Normal 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;
|
||||
109
frontend/src/pages/SelectReservation.jsx
Normal file
109
frontend/src/pages/SelectReservation.jsx
Normal 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;
|
||||
29
frontend/src/pages/Settings.jsx
Normal file
29
frontend/src/pages/Settings.jsx
Normal 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;
|
||||
16
frontend/src/pages/Test.jsx
Normal file
16
frontend/src/pages/Test.jsx
Normal 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;
|
||||
15
frontend/src/pages/Ticket.jsx
Normal file
15
frontend/src/pages/Ticket.jsx
Normal 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;
|
||||
0
frontend/src/pages/error/403.jsx
Normal file
0
frontend/src/pages/error/403.jsx
Normal file
0
frontend/src/pages/error/404.jsx
Normal file
0
frontend/src/pages/error/404.jsx
Normal file
0
frontend/src/pages/error/500.jsx
Normal file
0
frontend/src/pages/error/500.jsx
Normal file
421
frontend/src/pages/manager/Bin.jsx
Normal file
421
frontend/src/pages/manager/Bin.jsx
Normal 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;
|
||||
621
frontend/src/pages/manager/Events.jsx
Normal file
621
frontend/src/pages/manager/Events.jsx
Normal 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} Kč` : "—",
|
||||
},
|
||||
{
|
||||
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 m²:</strong> {selectedEvent.price_per_m2 ? `${selectedEvent.price_per_m2} Kč` : "—"}</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;
|
||||
411
frontend/src/pages/manager/Orders.jsx
Normal file
411
frontend/src/pages/manager/Orders.jsx
Normal 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;
|
||||
251
frontend/src/pages/manager/Products.jsx
Normal file
251
frontend/src/pages/manager/Products.jsx
Normal 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;
|
||||
670
frontend/src/pages/manager/Reservations.jsx
Normal file
670
frontend/src/pages/manager/Reservations.jsx
Normal 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} Kč
|
||||
</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} Kč</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 (Kč)</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;
|
||||
269
frontend/src/pages/manager/SquareDetail.jsx
Normal file
269
frontend/src/pages/manager/SquareDetail.jsx
Normal 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} m²)</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 (m², 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>
|
||||
);
|
||||
}
|
||||
368
frontend/src/pages/manager/Squares.jsx
Normal file
368
frontend/src/pages/manager/Squares.jsx
Normal 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;
|
||||
13
frontend/src/pages/manager/UserSettings.jsx
Normal file
13
frontend/src/pages/manager/UserSettings.jsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import UserSettings from "../../components/User-Settings";
|
||||
|
||||
|
||||
function Settings(){
|
||||
|
||||
return(
|
||||
<div>
|
||||
<UserSettings />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Settings
|
||||
795
frontend/src/pages/manager/Users.jsx
Normal file
795
frontend/src/pages/manager/Users.jsx
Normal 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;
|
||||
6
frontend/src/pages/manager/create/Kde je zbytek.md
Normal file
6
frontend/src/pages/manager/create/Kde je zbytek.md
Normal 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
|
||||
|
||||
273
frontend/src/pages/manager/create/SquareDesigner.jsx
Normal file
273
frontend/src/pages/manager/create/SquareDesigner.jsx
Normal 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} m²)</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 (m², 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>
|
||||
);
|
||||
}
|
||||
244
frontend/src/pages/manager/create/create-event.jsx
Normal file
244
frontend/src/pages/manager/create/create-event.jsx
Normal 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 m²</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>
|
||||
);
|
||||
}
|
||||
93
frontend/src/pages/manager/create/create-product.jsx
Normal file
93
frontend/src/pages/manager/create/create-product.jsx
Normal 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 (Kč)</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;
|
||||
283
frontend/src/pages/manager/create/create-user.jsx
Normal file
283
frontend/src/pages/manager/create/create-user.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
266
frontend/src/pages/manager/edit/MapEditor.jsx
Normal file
266
frontend/src/pages/manager/edit/MapEditor.jsx
Normal 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 (m²):
|
||||
<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í (m²):
|
||||
<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;
|
||||
17
frontend/src/pages/register/EmailVerification.jsx
Normal file
17
frontend/src/pages/register/EmailVerification.jsx
Normal 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;
|
||||
84
frontend/src/pages/register/Register.jsx
Normal file
84
frontend/src/pages/register/Register.jsx
Normal 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
8
frontend/vite.config.js
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
|
||||
})
|
||||
Reference in New Issue
Block a user