feat: mobilni responsivita, testy, klavesove zkratky, drag & drop, univerzalizace
- Mobile responsive CSS (touch targets 44px, iOS anti-zoom, reduced motion) - Vitest setup s 39 testy (formatters, attendanceHelpers, useTableSort) - Klavesove zkratky (Shift+? napoveda, Ctrl+S ulozit, navigace) - Drag & drop pro polozky nabidek (@dnd-kit, SortableRow, useSortableList) - Univerzalizace: odstraneni BOHA brandingu z UI, emailu, PDF - Smazany nepotrebne soubory (deploy.sh, AUTH_SYSTEM.md, example_design, .htaccess) - CORS konfigurovatelny pres env promennou Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -21,7 +21,15 @@
|
|||||||
"Read(//tmp/**)",
|
"Read(//tmp/**)",
|
||||||
"Bash(read f:*)",
|
"Bash(read f:*)",
|
||||||
"Bash(find D:\\\\Weby\\\\BOHA Website\\\\New\\\\api:*)",
|
"Bash(find D:\\\\Weby\\\\BOHA Website\\\\New\\\\api:*)",
|
||||||
"Bash(find:*)"
|
"Bash(find:*)",
|
||||||
|
"Bash(cd \"D:\\\\Claude\\\\BOHA Website\\\\app\" && npx vite build --mode development 2>&1 | grep -E \"built|error|✓\" | head -10)",
|
||||||
|
"Bash(cd \"D:\\\\Claude\\\\BOHA Website\\\\app\" && for f in api/admin/*.php; do php -l \"$f\" 2>&1; done)",
|
||||||
|
"Bash(cd \"D:\\\\Claude\\\\BOHA Website\\\\app\" && for f in api/admin/handlers/*.php; do php -l \"$f\" 2>&1; done)",
|
||||||
|
"Bash(cd \"D:\\\\Claude\\\\BOHA Website\\\\app\" && \\\\\necho \"=== session \\(no auth\\) ===\" && \\\\\ncurl -s http://localhost:8000/api/admin/session.php 2>&1 && \\\\\necho && echo \"=== refresh \\(no cookie\\) ===\" && \\\\\ncurl -s -X POST http://localhost:8000/api/admin/refresh.php 2>&1 && \\\\\necho && echo \"=== logout \\(no auth\\) ===\" && \\\\\ncurl -s -X POST http://localhost:8000/api/admin/logout.php 2>&1)",
|
||||||
|
"Bash(for f:*)",
|
||||||
|
"Bash(do echo:*)",
|
||||||
|
"Read(//d/Claude/BOHA Website/app/**)",
|
||||||
|
"Bash(done)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ Před začátkem práce si načti relevantní soubory z `memory/`:
|
|||||||
- **Vite proxy:** `/api` -> `http://localhost:8000`
|
- **Vite proxy:** `/api` -> `http://localhost:8000`
|
||||||
- **npm:** při instalaci balíčků vždy `--legacy-peer-deps`
|
- **npm:** při instalaci balíčků vždy `--legacy-peer-deps`
|
||||||
- **Git remote:** https://git.boha-automation.cz/boha_admin/app.git
|
- **Git remote:** https://git.boha-automation.cz/boha_admin/app.git
|
||||||
|
- **Git credentials:** uloženy v `~/.git-credentials` (credential.helper=store), push funguje bez zadávání hesla
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* BOHA Automation - Invoice PDF Export (Print-ready HTML)
|
* Invoice PDF Export (Print-ready HTML)
|
||||||
*
|
*
|
||||||
* Generuje HTML fakturu dle vzoru POHODA s QR platebnim kodem (SPAYD).
|
* Generuje HTML fakturu dle vzoru POHODA s QR platebnim kodem (SPAYD).
|
||||||
* GET /api/admin/invoices-pdf.php?id=X
|
* GET /api/admin/invoices-pdf.php?id=X
|
||||||
@@ -361,7 +361,7 @@ try {
|
|||||||
$custDic = $customer ? $esc($customer['vat_id'] ?? '') : '';
|
$custDic = $customer ? $esc($customer['vat_id'] ?? '') : '';
|
||||||
|
|
||||||
// Dodavatel
|
// Dodavatel
|
||||||
$suppName = $esc($settings['company_name'] ?? 'BOHA Automation s.r.o.');
|
$suppName = $esc($settings['company_name'] ?? '');
|
||||||
$suppStreet = $esc($settings['street'] ?? '');
|
$suppStreet = $esc($settings['street'] ?? '');
|
||||||
$suppCity = trim(($settings['postal_code'] ?? '') . ' ' . ($settings['city'] ?? ''));
|
$suppCity = trim(($settings['postal_code'] ?? '') . ' ' . ($settings['city'] ?? ''));
|
||||||
$suppCountry = $esc($settings['country'] ?? 'Česká republika');
|
$suppCountry = $esc($settings['country'] ?? 'Česká republika');
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* BOHA Automation - Offers PDF Export (Print-ready HTML)
|
* Offers PDF Export (Print-ready HTML)
|
||||||
*
|
*
|
||||||
* Returns a self-contained HTML page that auto-triggers window.print().
|
* Returns a self-contained HTML page that auto-triggers window.print().
|
||||||
* The browser's "Save as PDF" produces the final PDF.
|
* The browser's "Save as PDF" produces the final PDF.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* BOHA Automation - TOTP 2FA API
|
* TOTP 2FA API
|
||||||
*
|
*
|
||||||
* GET ?action=status - 2FA status
|
* GET ?action=status - 2FA status
|
||||||
* POST ?action=setup - generovat secret + QR
|
* POST ?action=setup - generovat secret + QR
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* BOHA Automation - API Configuration Bootstrap
|
* API Configuration Bootstrap
|
||||||
*
|
*
|
||||||
* Nacte helper funkce, env promenne a konstanty.
|
* Nacte helper funkce, env promenne a konstanty.
|
||||||
* Toto je jediny soubor, ktery API endpointy musi require_once.
|
* Toto je jediny soubor, ktery API endpointy musi require_once.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* BOHA Automation - Audit Logging System
|
* Audit Logging System
|
||||||
*
|
*
|
||||||
* Comprehensive audit trail for all administrative actions
|
* Comprehensive audit trail for all administrative actions
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* BOHA Automation - JWT Authentication Handler
|
* JWT Authentication Handler
|
||||||
*
|
*
|
||||||
* Handles JWT access tokens and refresh tokens for stateless authentication.
|
* Handles JWT access tokens and refresh tokens for stateless authentication.
|
||||||
* Access tokens: Short-lived (configurable, default 15 min), stored in memory on client
|
* Access tokens: Short-lived (configurable, default 15 min), stored in memory on client
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* BOHA Automation - Leave Request Email Notifications
|
* Leave Request Email Notifications
|
||||||
*
|
*
|
||||||
* Sends email notifications when leave requests are created.
|
* Sends email notifications when leave requests are created.
|
||||||
*/
|
*/
|
||||||
@@ -66,16 +66,17 @@ class LeaveNotification
|
|||||||
<td style='padding: 10px; border-bottom: 1px solid #ddd;'>" . htmlspecialchars($notes) . '</td>
|
<td style='padding: 10px; border-bottom: 1px solid #ddd;'>" . htmlspecialchars($notes) . '</td>
|
||||||
</tr>' : '') . "
|
</tr>' : '') . "
|
||||||
</table>
|
</table>
|
||||||
|
" . (env('APP_URL', '') ? "
|
||||||
<p style='margin-top: 20px;'>
|
<p style='margin-top: 20px;'>
|
||||||
<a href='https://www.boha-automation.cz/boha/leave-approval'
|
<a href='" . htmlspecialchars(env('APP_URL', '')) . "/leave-approval'
|
||||||
style='background: #de3a3a; color: #fff; padding: 10px 20px;
|
style='background: #de3a3a; color: #fff; padding: 10px 20px;
|
||||||
text-decoration: none; border-radius: 5px;'>
|
text-decoration: none; border-radius: 5px;'>
|
||||||
Přejít ke schvalování
|
Přejít ke schvalování
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>" : "") . "
|
||||||
<hr style='margin: 30px 0; border: none; border-top: 1px solid #ddd;'>
|
<hr style='margin: 30px 0; border: none; border-top: 1px solid #ddd;'>
|
||||||
<p style='font-size: 12px; color: #999;'>
|
<p style='font-size: 12px; color: #999;'>
|
||||||
Tato zpráva byla automaticky vygenerována systémem BOHA Automation.<br>
|
Tato zpráva byla automaticky vygenerována systémem.<br>
|
||||||
Datum: " . date('d.m.Y H:i:s') . '
|
Datum: " . date('d.m.Y H:i:s') . '
|
||||||
</p>
|
</p>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* BOHA Automation - Email Helper
|
* Email Helper
|
||||||
*
|
*
|
||||||
* Sends emails via PHP mail() function.
|
* Sends emails via PHP mail() function.
|
||||||
* Configuration via .env variables.
|
* Configuration via .env variables.
|
||||||
@@ -23,7 +23,7 @@ class Mailer
|
|||||||
public static function send(string $to, string $subject, string $htmlBody, ?string $replyTo = null): bool
|
public static function send(string $to, string $subject, string $htmlBody, ?string $replyTo = null): bool
|
||||||
{
|
{
|
||||||
$fromEmail = env('SMTP_FROM_EMAIL', env('CONTACT_EMAIL_FROM', 'web@boha-automation.cz'));
|
$fromEmail = env('SMTP_FROM_EMAIL', env('CONTACT_EMAIL_FROM', 'web@boha-automation.cz'));
|
||||||
$fromName = env('SMTP_FROM_NAME', 'BOHA Automation');
|
$fromName = env('SMTP_FROM_NAME', 'System');
|
||||||
|
|
||||||
$encodedSubject = '=?UTF-8?B?' . base64_encode($subject) . '?=';
|
$encodedSubject = '=?UTF-8?B?' . base64_encode($subject) . '?=';
|
||||||
|
|
||||||
|
|||||||
@@ -25,11 +25,10 @@ define('MAX_LOGIN_ATTEMPTS', 5);
|
|||||||
define('LOCKOUT_MINUTES', 15);
|
define('LOCKOUT_MINUTES', 15);
|
||||||
define('BCRYPT_COST', 12);
|
define('BCRYPT_COST', 12);
|
||||||
|
|
||||||
// CORS - aktualizuj po nasazeni na subdomenu
|
// CORS - konfigurovatelne pres env (comma-separated), fallback na hardcoded hodnoty
|
||||||
define('CORS_ALLOWED_ORIGINS', [
|
define('CORS_ALLOWED_ORIGINS', env('CORS_ALLOWED_ORIGINS', '')
|
||||||
'http://www.boha-automation.cz',
|
? array_map('trim', explode(',', (string) env('CORS_ALLOWED_ORIGINS', '')))
|
||||||
'https://www.boha-automation.cz',
|
: ['http://www.boha-automation.cz', 'https://www.boha-automation.cz']);
|
||||||
]);
|
|
||||||
|
|
||||||
// Paths
|
// Paths
|
||||||
define('API_ROOT', dirname(__DIR__));
|
define('API_ROOT', dirname(__DIR__));
|
||||||
|
|||||||
33
deploy.sh
33
deploy.sh
@@ -1,33 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
set -e
|
|
||||||
|
|
||||||
DIST_DIR="D:/Weby/BOHA Website/New/dist"
|
|
||||||
HTDOCS_DIR="/c/Apache24/htdocs"
|
|
||||||
|
|
||||||
echo "=== BOHA Automation Deploy ==="
|
|
||||||
|
|
||||||
# 1. Build
|
|
||||||
echo "[1/4] Building..."
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
# 2. Verify build
|
|
||||||
if [ ! -f "$DIST_DIR/index.html" ] || [ ! -d "$DIST_DIR/api" ]; then
|
|
||||||
echo "ERROR: Build incomplete - missing index.html or api/"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "[2/4] Build verified OK"
|
|
||||||
|
|
||||||
# 3. Deploy
|
|
||||||
echo "[3/4] Deploying to Apache..."
|
|
||||||
rm -rf "$HTDOCS_DIR"/*
|
|
||||||
cp -r "$DIST_DIR"/* "$HTDOCS_DIR"/
|
|
||||||
|
|
||||||
# 4. Health check
|
|
||||||
echo "[4/4] Health check..."
|
|
||||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost/ 2>/dev/null || echo "000")
|
|
||||||
if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "302" ]; then
|
|
||||||
echo "=== Deploy OK (HTTP $HTTP_CODE) ==="
|
|
||||||
else
|
|
||||||
echo "WARNING: Health check returned HTTP $HTTP_CODE (Apache may not be running)"
|
|
||||||
fi
|
|
||||||
2621
docs/AUTH_SYSTEM.md
2621
docs/AUTH_SYSTEM.md
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -9,10 +9,10 @@
|
|||||||
<meta http-equiv="Pragma" content="no-cache" />
|
<meta http-equiv="Pragma" content="no-cache" />
|
||||||
<meta http-equiv="Expires" content="0" />
|
<meta http-equiv="Expires" content="0" />
|
||||||
|
|
||||||
<title>BOHA Interni system</title>
|
<title>Interní systém</title>
|
||||||
|
|
||||||
<meta name="robots" content="noindex, nofollow" />
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
<meta name="apple-mobile-web-app-title" content="BOHA System" />
|
<meta name="apple-mobile-web-app-title" content="Interní systém" />
|
||||||
|
|
||||||
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
|||||||
1856
package-lock.json
generated
1856
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
15
package.json
15
package.json
@@ -8,9 +8,15 @@
|
|||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"deploy": "bash deploy.sh",
|
"deploy": "bash deploy.sh",
|
||||||
"lint": "eslint src/"
|
"lint": "eslint src/",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/modifiers": "^9.0.0",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"dompurify": "^3.3.1",
|
"dompurify": "^3.3.1",
|
||||||
"framer-motion": "^12.23.25",
|
"framer-motion": "^12.23.25",
|
||||||
@@ -23,11 +29,16 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@testing-library/dom": "^10.4.1",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
"@vitejs/plugin-react": "^4.7.0",
|
"@vitejs/plugin-react": "^4.7.0",
|
||||||
"eslint": "^10.0.2",
|
"eslint": "^10.0.2",
|
||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
"globals": "^17.4.0",
|
"globals": "^17.4.0",
|
||||||
"vite": "^5.4.11"
|
"jsdom": "^28.1.0",
|
||||||
|
"vite": "^5.4.11",
|
||||||
|
"vitest": "^4.0.18"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
<FilesMatch "^\.env">
|
|
||||||
Order allow,deny
|
|
||||||
Deny from all
|
|
||||||
</FilesMatch>
|
|
||||||
|
|
||||||
<FilesMatch "\.(log|sql|bak|backup|db|ini)$">
|
|
||||||
Order allow,deny
|
|
||||||
Deny from all
|
|
||||||
</FilesMatch>
|
|
||||||
|
|
||||||
Options -Indexes
|
|
||||||
|
|
||||||
AddDefaultCharset UTF-8
|
|
||||||
<IfModule mod_mime.c>
|
|
||||||
AddCharset UTF-8 .html .css .js .json .xml .txt
|
|
||||||
</IfModule>
|
|
||||||
|
|
||||||
<IfModule mod_headers.c>
|
|
||||||
Header set X-Content-Type-Options "nosniff"
|
|
||||||
Header set X-Frame-Options "SAMEORIGIN"
|
|
||||||
Header set Referrer-Policy "strict-origin-when-cross-origin"
|
|
||||||
Header set Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
|
||||||
Header set Permissions-Policy "camera=(), microphone=(), geolocation=(self)"
|
|
||||||
Header set Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob:; connect-src 'self'"
|
|
||||||
</IfModule>
|
|
||||||
|
|
||||||
<IfModule mod_rewrite.c>
|
|
||||||
RewriteEngine On
|
|
||||||
RewriteBase /
|
|
||||||
|
|
||||||
# Force HTTPS
|
|
||||||
RewriteCond %{HTTPS} off
|
|
||||||
RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
|
|
||||||
|
|
||||||
RewriteRule ^api/ - [L]
|
|
||||||
|
|
||||||
RewriteCond %{REQUEST_FILENAME} -f [OR]
|
|
||||||
RewriteCond %{REQUEST_FILENAME} -d
|
|
||||||
RewriteRule ^ - [L]
|
|
||||||
|
|
||||||
# All SPA routes go through router.php
|
|
||||||
RewriteRule ^ /router.php [L]
|
|
||||||
</IfModule>
|
|
||||||
|
|
||||||
<IfModule mod_deflate.c>
|
|
||||||
AddOutputFilterByType DEFLATE text/plain text/html text/xml text/css
|
|
||||||
AddOutputFilterByType DEFLATE application/xml application/xhtml+xml application/rss+xml
|
|
||||||
AddOutputFilterByType DEFLATE application/javascript application/x-javascript application/json
|
|
||||||
AddOutputFilterByType DEFLATE image/svg+xml application/font-woff2
|
|
||||||
SetEnvIfNoCase Request_URI "\.(jpg|jpeg|png|gif|webp|zip|gz|br|woff2)$" no-gzip
|
|
||||||
</IfModule>
|
|
||||||
|
|
||||||
<IfModule mod_expires.c>
|
|
||||||
ExpiresActive On
|
|
||||||
ExpiresByType image/jpg "access plus 1 year"
|
|
||||||
ExpiresByType image/jpeg "access plus 1 year"
|
|
||||||
ExpiresByType image/gif "access plus 1 year"
|
|
||||||
ExpiresByType image/png "access plus 1 year"
|
|
||||||
ExpiresByType image/svg+xml "access plus 1 year"
|
|
||||||
ExpiresByType text/css "access plus 1 year"
|
|
||||||
ExpiresByType application/javascript "access plus 1 year"
|
|
||||||
ExpiresByType text/javascript "access plus 1 year"
|
|
||||||
ExpiresByType application/font-woff2 "access plus 1 year"
|
|
||||||
ExpiresByType text/html "access plus 0 seconds"
|
|
||||||
</IfModule>
|
|
||||||
|
|
||||||
<FilesMatch "index\.html$">
|
|
||||||
<IfModule mod_headers.c>
|
|
||||||
Header set Cache-Control "no-cache, no-store, must-revalidate"
|
|
||||||
Header set Pragma "no-cache"
|
|
||||||
Header set Expires "0"
|
|
||||||
</IfModule>
|
|
||||||
</FilesMatch>
|
|
||||||
|
|
||||||
<FilesMatch "\.php$">
|
|
||||||
<IfModule mod_headers.c>
|
|
||||||
Header set Cache-Control "no-store, no-cache, must-revalidate, max-age=0"
|
|
||||||
Header set Pragma "no-cache"
|
|
||||||
Header set Expires "0"
|
|
||||||
</IfModule>
|
|
||||||
</FilesMatch>
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "BOHA",
|
"name": "System",
|
||||||
"short_name": "BOHA",
|
"short_name": "System",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "/web-app-manifest-192x192.png",
|
"src": "/web-app-manifest-192x192.png",
|
||||||
|
|||||||
@@ -2148,3 +2148,200 @@ img {
|
|||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
Mobile Responsive Enhancements
|
||||||
|
============================================================================ */
|
||||||
|
|
||||||
|
/* Touch targets - min 44px na mobilech */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.admin-btn {
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-btn-sm {
|
||||||
|
min-height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-btn-icon {
|
||||||
|
min-width: 44px;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-form-input,
|
||||||
|
.admin-form-select,
|
||||||
|
.admin-form-textarea {
|
||||||
|
min-height: 44px;
|
||||||
|
font-size: 16px; /* zabrání auto-zoomu na iOS */
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-form-checkbox {
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-form-checkbox input + span::before {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-form-label {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tabulky - kompaktnejsi na mobilech, lepsi scroll indikace */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.admin-table-wrapper,
|
||||||
|
.admin-table-responsive {
|
||||||
|
margin: 0 -1rem;
|
||||||
|
padding: 0 1rem;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table-wrapper::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 24px;
|
||||||
|
background: linear-gradient(to right, transparent, var(--bg-primary));
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table {
|
||||||
|
min-width: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table th,
|
||||||
|
.admin-table td {
|
||||||
|
padding: 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table th {
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table-actions {
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Page header na mobilech */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.admin-page-title {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-page-subtitle {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-content {
|
||||||
|
padding: 12px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-card-body {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-card-header {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grid - single column na malych mobilech */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.admin-grid-4 {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Confirm modal - ne fullscreen na mobilech */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.admin-confirm-content {
|
||||||
|
padding: 1.5rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-confirm-title {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-confirm-message {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Skeleton loading na mobilech */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.admin-skeleton {
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badge na mobilech - vetsi pro touch */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.admin-badge {
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.admin-badge {
|
||||||
|
min-height: 32px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prefers reduced motion */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Drag handle */
|
||||||
|
.admin-drag-handle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: grab;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0;
|
||||||
|
transition: color 0.15s, background 0.15s;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-drag-handle:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-drag-handle:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keyboard shortcut badge */
|
||||||
|
.admin-kbd {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 7px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useTheme } from '../../context/ThemeContext'
|
|||||||
import { setLogoutAlert } from '../utils/api'
|
import { setLogoutAlert } from '../utils/api'
|
||||||
import useModalLock from '../hooks/useModalLock'
|
import useModalLock from '../hooks/useModalLock'
|
||||||
import Sidebar from './Sidebar'
|
import Sidebar from './Sidebar'
|
||||||
|
import ShortcutsHelp from './ShortcutsHelp'
|
||||||
|
|
||||||
export default function AdminLayout() {
|
export default function AdminLayout() {
|
||||||
const { isAuthenticated, loading, checkSession, user, logout } = useAuth()
|
const { isAuthenticated, loading, checkSession, user, logout } = useAuth()
|
||||||
@@ -101,6 +102,7 @@ export default function AdminLayout() {
|
|||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
<ShortcutsHelp />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,23 @@
|
|||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
|
import { DndContext, closestCenter, KeyboardSensor, PointerSensor, TouchSensor, useSensor, useSensors } from '@dnd-kit/core'
|
||||||
|
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
|
||||||
|
import { restrictToVerticalAxis } from '@dnd-kit/modifiers'
|
||||||
import { formatCurrency } from '../utils/formatters'
|
import { formatCurrency } from '../utils/formatters'
|
||||||
|
import SortableRow, { DragHandle } from './SortableRow'
|
||||||
|
import useSortableList from '../hooks/useSortableList'
|
||||||
|
|
||||||
export default function OfferItemsSection({
|
export default function OfferItemsSection({
|
||||||
items, updateItem, addItem, removeItem, moveItem,
|
items, setItems, updateItem, addItem, removeItem,
|
||||||
itemTemplates, showItemTemplateMenu, setShowItemTemplateMenu,
|
itemTemplates, showItemTemplateMenu, setShowItemTemplateMenu,
|
||||||
addItemFromTemplate, totals, currency, applyVat, vatRate,
|
addItemFromTemplate, totals, currency, applyVat, vatRate,
|
||||||
itemsError, readOnly
|
itemsError, readOnly
|
||||||
}) {
|
}) {
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||||
|
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 5 } }),
|
||||||
|
useSensor(KeyboardSensor)
|
||||||
|
)
|
||||||
|
const { handleDragEnd } = useSortableList(setItems, '_key')
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="offers-editor-section"
|
className="offers-editor-section"
|
||||||
@@ -61,6 +72,7 @@ export default function OfferItemsSection({
|
|||||||
<table className="admin-table">
|
<table className="admin-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
{!readOnly && <th style={{ width: '2rem' }}></th>}
|
||||||
<th style={{ width: '2.5rem', textAlign: 'center' }}>#</th>
|
<th style={{ width: '2.5rem', textAlign: 'center' }}>#</th>
|
||||||
<th>Popis položky</th>
|
<th>Popis položky</th>
|
||||||
<th style={{ width: '5.5rem', textAlign: 'center' }}>Množství</th>
|
<th style={{ width: '5.5rem', textAlign: 'center' }}>Množství</th>
|
||||||
@@ -68,107 +80,112 @@ export default function OfferItemsSection({
|
|||||||
<th style={{ width: '5.5rem', textAlign: 'center' }}>Jedn. cena</th>
|
<th style={{ width: '5.5rem', textAlign: 'center' }}>Jedn. cena</th>
|
||||||
<th style={{ width: '4.5rem', textAlign: 'center' }}>V ceně</th>
|
<th style={{ width: '4.5rem', textAlign: 'center' }}>V ceně</th>
|
||||||
<th style={{ width: '8rem', textAlign: 'right' }}>Celkem</th>
|
<th style={{ width: '8rem', textAlign: 'right' }}>Celkem</th>
|
||||||
{!readOnly && <th style={{ width: '5.5rem', textAlign: 'center' }}></th>}
|
{!readOnly && <th style={{ width: '2.5rem', textAlign: 'center' }}></th>}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd} modifiers={[restrictToVerticalAxis]}>
|
||||||
{items.map((item, index) => {
|
<SortableContext items={items.map(i => String(i._key))} strategy={verticalListSortingStrategy}>
|
||||||
const lineTotal = (Number(item.quantity) || 0) * (Number(item.unit_price) || 0)
|
<tbody>
|
||||||
return (
|
{items.map((item, index) => {
|
||||||
<tr key={item._key || index}>
|
const lineTotal = (Number(item.quantity) || 0) * (Number(item.unit_price) || 0)
|
||||||
<td style={{ color: 'var(--text-tertiary)', textAlign: 'center', fontWeight: 500 }}>{index + 1}</td>
|
return (
|
||||||
<td>
|
<SortableRow key={item._key} id={String(item._key)} disabled={readOnly}>
|
||||||
<input
|
{({ attributes, listeners }) => (
|
||||||
type="text"
|
<>
|
||||||
value={item.description}
|
{!readOnly && (
|
||||||
onChange={(e) => updateItem(index, 'description', e.target.value)}
|
<td style={{ width: '2rem' }}>
|
||||||
className="admin-form-input"
|
<DragHandle listeners={listeners} attributes={attributes} />
|
||||||
placeholder="Název položky"
|
</td>
|
||||||
style={{ marginBottom: '0.5rem', fontWeight: 500 }}
|
)}
|
||||||
readOnly={readOnly}
|
<td style={{ color: 'var(--text-tertiary)', textAlign: 'center', fontWeight: 500 }}>{index + 1}</td>
|
||||||
/>
|
<td>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={item.item_description}
|
value={item.description}
|
||||||
onChange={(e) => updateItem(index, 'item_description', e.target.value)}
|
onChange={(e) => updateItem(index, 'description', e.target.value)}
|
||||||
className="admin-form-input"
|
className="admin-form-input"
|
||||||
placeholder="Podrobný popis (volitelný)"
|
placeholder="Název položky"
|
||||||
style={{ fontSize: '0.8rem', opacity: 0.8 }}
|
style={{ marginBottom: '0.5rem', fontWeight: 500 }}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
/>
|
/>
|
||||||
</td>
|
<input
|
||||||
<td>
|
type="text"
|
||||||
<input
|
value={item.item_description}
|
||||||
type="number"
|
onChange={(e) => updateItem(index, 'item_description', e.target.value)}
|
||||||
value={item.quantity}
|
className="admin-form-input"
|
||||||
onChange={(e) => updateItem(index, 'quantity', parseFloat(e.target.value) || 0)}
|
placeholder="Podrobný popis (volitelný)"
|
||||||
className="admin-form-input"
|
style={{ fontSize: '0.8rem', opacity: 0.8 }}
|
||||||
min="0"
|
readOnly={readOnly}
|
||||||
step="1"
|
/>
|
||||||
style={{ textAlign: 'center', height: '2.25rem', padding: '0.375rem 0.5rem' }}
|
</td>
|
||||||
readOnly={readOnly}
|
<td>
|
||||||
/>
|
<input
|
||||||
</td>
|
type="number"
|
||||||
<td>
|
value={item.quantity}
|
||||||
<input
|
onChange={(e) => updateItem(index, 'quantity', parseFloat(e.target.value) || 0)}
|
||||||
type="text"
|
className="admin-form-input"
|
||||||
value={item.unit}
|
min="0"
|
||||||
onChange={(e) => updateItem(index, 'unit', e.target.value)}
|
step="1"
|
||||||
className="admin-form-input"
|
style={{ textAlign: 'center', height: '2.25rem', padding: '0.375rem 0.5rem' }}
|
||||||
placeholder="hod"
|
readOnly={readOnly}
|
||||||
style={{ textAlign: 'center', height: '2.25rem', padding: '0.375rem 0.5rem' }}
|
/>
|
||||||
readOnly={readOnly}
|
</td>
|
||||||
/>
|
<td>
|
||||||
</td>
|
<input
|
||||||
<td>
|
type="text"
|
||||||
<input
|
value={item.unit}
|
||||||
type="number"
|
onChange={(e) => updateItem(index, 'unit', e.target.value)}
|
||||||
value={item.unit_price}
|
className="admin-form-input"
|
||||||
onChange={(e) => updateItem(index, 'unit_price', parseFloat(e.target.value) || 0)}
|
placeholder="hod"
|
||||||
className="admin-form-input"
|
style={{ textAlign: 'center', height: '2.25rem', padding: '0.375rem 0.5rem' }}
|
||||||
min="0"
|
readOnly={readOnly}
|
||||||
step="0.01"
|
/>
|
||||||
style={{ textAlign: 'right', height: '2.25rem', padding: '0.375rem 0.5rem' }}
|
</td>
|
||||||
readOnly={readOnly}
|
<td>
|
||||||
/>
|
<input
|
||||||
</td>
|
type="number"
|
||||||
<td style={{ textAlign: 'center' }}>
|
value={item.unit_price}
|
||||||
<label className="admin-form-checkbox" style={{ justifyContent: 'center' }}>
|
onChange={(e) => updateItem(index, 'unit_price', parseFloat(e.target.value) || 0)}
|
||||||
<input
|
className="admin-form-input"
|
||||||
type="checkbox"
|
min="0"
|
||||||
checked={item.is_included_in_total}
|
step="0.01"
|
||||||
onChange={(e) => updateItem(index, 'is_included_in_total', e.target.checked)}
|
style={{ textAlign: 'right', height: '2.25rem', padding: '0.375rem 0.5rem' }}
|
||||||
disabled={readOnly}
|
readOnly={readOnly}
|
||||||
/>
|
/>
|
||||||
<span></span>
|
</td>
|
||||||
</label>
|
<td style={{ textAlign: 'center' }}>
|
||||||
</td>
|
<label className="admin-form-checkbox" style={{ justifyContent: 'center' }}>
|
||||||
<td style={{ textAlign: 'right', fontWeight: 600, whiteSpace: 'nowrap', fontSize: '0.875rem' }}>
|
<input
|
||||||
{formatCurrency(lineTotal, currency)}
|
type="checkbox"
|
||||||
</td>
|
checked={item.is_included_in_total}
|
||||||
{!readOnly && (
|
onChange={(e) => updateItem(index, 'is_included_in_total', e.target.checked)}
|
||||||
<td>
|
disabled={readOnly}
|
||||||
<div style={{ display: 'flex', gap: '0.125rem', justifyContent: 'center' }}>
|
/>
|
||||||
<button type="button" onClick={() => moveItem(index, -1)} disabled={index === 0} className="admin-btn-icon" title="Nahoru" aria-label="Nahoru">
|
<span></span>
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M18 15l-6-6-6 6" /></svg>
|
</label>
|
||||||
</button>
|
</td>
|
||||||
<button type="button" onClick={() => moveItem(index, 1)} disabled={index === items.length - 1} className="admin-btn-icon" title="Dolů" aria-label="Dolů">
|
<td style={{ textAlign: 'right', fontWeight: 600, whiteSpace: 'nowrap', fontSize: '0.875rem' }}>
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M6 9l6 6 6-6" /></svg>
|
{formatCurrency(lineTotal, currency)}
|
||||||
</button>
|
</td>
|
||||||
{items.length > 1 && (
|
{!readOnly && (
|
||||||
<button type="button" onClick={() => removeItem(index)} className="admin-btn-icon danger" title="Odebrat" aria-label="Odebrat">
|
<td>
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
{items.length > 1 && (
|
||||||
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
<button type="button" onClick={() => removeItem(index)} className="admin-btn-icon danger" title="Odebrat" aria-label="Odebrat">
|
||||||
</svg>
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
</button>
|
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
||||||
)}
|
</svg>
|
||||||
</div>
|
</button>
|
||||||
</td>
|
)}
|
||||||
)}
|
</td>
|
||||||
</tr>
|
)}
|
||||||
)
|
</>
|
||||||
})}
|
)}
|
||||||
</tbody>
|
</SortableRow>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
50
src/admin/components/ShortcutsHelp.jsx
Normal file
50
src/admin/components/ShortcutsHelp.jsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import useKeyboardShortcuts from '../hooks/useKeyboardShortcuts'
|
||||||
|
|
||||||
|
const GLOBAL_SHORTCUTS = [
|
||||||
|
{ keys: '?', description: 'Zobrazit klávesové zkratky' },
|
||||||
|
{ keys: 'Ctrl + N', description: 'Nový záznam' },
|
||||||
|
{ keys: 'Ctrl + S', description: 'Uložit' },
|
||||||
|
{ keys: 'Escape', description: 'Zavřít modal / zrušit' },
|
||||||
|
{ keys: '/', description: 'Hledat' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function ShortcutsHelp() {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
|
useKeyboardShortcuts([
|
||||||
|
{ key: '?', shift: true, handler: () => setOpen(prev => !prev) },
|
||||||
|
{ key: 'Escape', handler: () => setOpen(false), when: open },
|
||||||
|
])
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-modal-overlay" onClick={() => setOpen(false)}>
|
||||||
|
<div className="admin-modal" style={{ maxWidth: 420 }} onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="admin-modal-header">
|
||||||
|
<h3>Klávesové zkratky</h3>
|
||||||
|
<button className="admin-modal-close" onClick={() => setOpen(false)}>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M18 6L6 18M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="admin-modal-body">
|
||||||
|
<table className="admin-table" style={{ minWidth: 'auto' }}>
|
||||||
|
<tbody>
|
||||||
|
{GLOBAL_SHORTCUTS.map(s => (
|
||||||
|
<tr key={s.keys}>
|
||||||
|
<td style={{ width: 120 }}>
|
||||||
|
<kbd className="admin-kbd">{s.keys}</kbd>
|
||||||
|
</td>
|
||||||
|
<td>{s.description}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
53
src/admin/components/SortableRow.jsx
Normal file
53
src/admin/components/SortableRow.jsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { useSortable } from '@dnd-kit/sortable'
|
||||||
|
import { CSS } from '@dnd-kit/utilities'
|
||||||
|
|
||||||
|
export function DragHandle({ listeners, attributes }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="admin-drag-handle"
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
title="Přetáhnout"
|
||||||
|
aria-label="Přetáhnout pro změnu pořadí"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<circle cx="9" cy="5" r="1.5" />
|
||||||
|
<circle cx="15" cy="5" r="1.5" />
|
||||||
|
<circle cx="9" cy="12" r="1.5" />
|
||||||
|
<circle cx="15" cy="12" r="1.5" />
|
||||||
|
<circle cx="9" cy="19" r="1.5" />
|
||||||
|
<circle cx="15" cy="19" r="1.5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SortableRow({ id, children, disabled }) {
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({ id, disabled })
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
opacity: isDragging ? 0.5 : 1,
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: isDragging ? 10 : undefined,
|
||||||
|
background: isDragging ? 'var(--bg-secondary)' : undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr ref={setNodeRef} style={style}>
|
||||||
|
{typeof children === 'function'
|
||||||
|
? children({ attributes, listeners })
|
||||||
|
: children
|
||||||
|
}
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
}
|
||||||
65
src/admin/hooks/__tests__/useTableSort.test.js
Normal file
65
src/admin/hooks/__tests__/useTableSort.test.js
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { renderHook, act } from '@testing-library/react'
|
||||||
|
import useTableSort from '../useTableSort'
|
||||||
|
|
||||||
|
describe('useTableSort', () => {
|
||||||
|
it('vraci pocatecni stav s default hodnotami', () => {
|
||||||
|
const { result } = renderHook(() => useTableSort('name'))
|
||||||
|
expect(result.current.sort).toBe('name')
|
||||||
|
expect(result.current.order).toBe('DESC')
|
||||||
|
expect(result.current.activeSort).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('respektuje custom pocatecni order', () => {
|
||||||
|
const { result } = renderHook(() => useTableSort('date', 'ASC'))
|
||||||
|
expect(result.current.sort).toBe('date')
|
||||||
|
expect(result.current.order).toBe('ASC')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('activeSort je null dokud uzivatel neklikne', () => {
|
||||||
|
const { result } = renderHook(() => useTableSort('name'))
|
||||||
|
expect(result.current.activeSort).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('po kliknuti na sloupec nastavi activeSort', () => {
|
||||||
|
const { result } = renderHook(() => useTableSort('name'))
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleSort('name')
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.activeSort).toBe('name')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('toggleuje order pri kliknuti na stejny sloupec', () => {
|
||||||
|
const { result } = renderHook(() => useTableSort('name'))
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleSort('name')
|
||||||
|
})
|
||||||
|
// Default DESC -> toggle to ASC
|
||||||
|
expect(result.current.order).toBe('ASC')
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleSort('name')
|
||||||
|
})
|
||||||
|
expect(result.current.order).toBe('DESC')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pri kliknuti na jiny sloupec resetuje order na DESC', () => {
|
||||||
|
const { result } = renderHook(() => useTableSort('name'))
|
||||||
|
|
||||||
|
// Klikneme na name - toggle z DESC na ASC
|
||||||
|
act(() => {
|
||||||
|
result.current.handleSort('name')
|
||||||
|
})
|
||||||
|
expect(result.current.order).toBe('ASC')
|
||||||
|
|
||||||
|
// Klikneme na jiny sloupec - reset na DESC
|
||||||
|
act(() => {
|
||||||
|
result.current.handleSort('date')
|
||||||
|
})
|
||||||
|
expect(result.current.sort).toBe('date')
|
||||||
|
expect(result.current.order).toBe('DESC')
|
||||||
|
})
|
||||||
|
})
|
||||||
36
src/admin/hooks/useKeyboardShortcuts.js
Normal file
36
src/admin/hooks/useKeyboardShortcuts.js
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { useEffect, useCallback } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook pro globalni keyboard shortcuts
|
||||||
|
* @param {Array<{key: string, ctrl?: boolean, shift?: boolean, alt?: boolean, handler: Function, when?: boolean}>} shortcuts
|
||||||
|
*/
|
||||||
|
export default function useKeyboardShortcuts(shortcuts) {
|
||||||
|
const handleKeyDown = useCallback((e) => {
|
||||||
|
// Ignorovat pokud je focus v inputu/textarea/contenteditable (krome Escape)
|
||||||
|
const tag = e.target.tagName
|
||||||
|
const isInput = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || e.target.isContentEditable
|
||||||
|
|
||||||
|
for (const shortcut of shortcuts) {
|
||||||
|
if (shortcut.when === false) continue
|
||||||
|
|
||||||
|
const ctrlMatch = shortcut.ctrl ? (e.ctrlKey || e.metaKey) : !(e.ctrlKey || e.metaKey)
|
||||||
|
const shiftMatch = shortcut.shift ? e.shiftKey : !e.shiftKey
|
||||||
|
const altMatch = shortcut.alt ? e.altKey : !e.altKey
|
||||||
|
const keyMatch = e.key.toLowerCase() === shortcut.key.toLowerCase()
|
||||||
|
|
||||||
|
if (keyMatch && ctrlMatch && shiftMatch && altMatch) {
|
||||||
|
// Escape funguje i v inputech
|
||||||
|
if (isInput && e.key !== 'Escape') continue
|
||||||
|
|
||||||
|
e.preventDefault()
|
||||||
|
shortcut.handler(e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [shortcuts])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [handleKeyDown])
|
||||||
|
}
|
||||||
29
src/admin/hooks/useSortableList.js
Normal file
29
src/admin/hooks/useSortableList.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { useCallback } from 'react'
|
||||||
|
import { arrayMove } from '@dnd-kit/sortable'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook pro drag-and-drop razeni seznamu
|
||||||
|
* Vraci handleDragEnd pro DndContext
|
||||||
|
*
|
||||||
|
* @param {Function} setItems - setter pro pole polozek
|
||||||
|
* @param {string} keyField - nazev property pro unikatni identifikaci (_key, id)
|
||||||
|
*/
|
||||||
|
export default function useSortableList(setItems, keyField = '_key') {
|
||||||
|
const handleDragEnd = useCallback((event) => {
|
||||||
|
const { active, over } = event
|
||||||
|
if (!over || active.id === over.id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setItems(prev => {
|
||||||
|
const oldIndex = prev.findIndex(item => String(item[keyField]) === String(active.id))
|
||||||
|
const newIndex = prev.findIndex(item => String(item[keyField]) === String(over.id))
|
||||||
|
if (oldIndex === -1 || newIndex === -1) {
|
||||||
|
return prev
|
||||||
|
}
|
||||||
|
return arrayMove(prev, oldIndex, newIndex)
|
||||||
|
})
|
||||||
|
}, [setItems, keyField])
|
||||||
|
|
||||||
|
return { handleDragEnd }
|
||||||
|
}
|
||||||
@@ -124,3 +124,18 @@
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.invoice-month-btn {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.received-upload-row {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.received-upload-file-name {
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ export default function Login() {
|
|||||||
<div className="admin-login-header">
|
<div className="admin-login-header">
|
||||||
<img
|
<img
|
||||||
src={theme === 'dark' ? '/images/logo-dark.png' : '/images/logo-light.png'}
|
src={theme === 'dark' ? '/images/logo-dark.png' : '/images/logo-light.png'}
|
||||||
alt="BOHA Automation"
|
alt="Logo"
|
||||||
className="admin-login-logo"
|
className="admin-login-logo"
|
||||||
/>
|
/>
|
||||||
<h1 className="admin-login-title">Interní systém</h1>
|
<h1 className="admin-login-title">Interní systém</h1>
|
||||||
|
|||||||
@@ -330,16 +330,6 @@ export default function OfferDetail() {
|
|||||||
setItems(prev => prev.length > 1 ? prev.filter((_, i) => i !== index) : prev)
|
setItems(prev => prev.length > 1 ? prev.filter((_, i) => i !== index) : prev)
|
||||||
}
|
}
|
||||||
|
|
||||||
const moveItem = (index, direction) => {
|
|
||||||
setItems(prev => {
|
|
||||||
const newItems = [...prev]
|
|
||||||
const target = index + direction
|
|
||||||
if (target < 0 || target >= newItems.length) return prev
|
|
||||||
;[newItems[index], newItems[target]] = [newItems[target], newItems[index]]
|
|
||||||
return newItems
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const addItemFromTemplate = (template) => {
|
const addItemFromTemplate = (template) => {
|
||||||
setItems(prev => [...prev, {
|
setItems(prev => [...prev, {
|
||||||
_key: `item-${++_keyCounter}`,
|
_key: `item-${++_keyCounter}`,
|
||||||
@@ -921,10 +911,10 @@ export default function OfferDetail() {
|
|||||||
|
|
||||||
<OfferItemsSection
|
<OfferItemsSection
|
||||||
items={items}
|
items={items}
|
||||||
|
setItems={setItems}
|
||||||
updateItem={updateItem}
|
updateItem={updateItem}
|
||||||
addItem={addItem}
|
addItem={addItem}
|
||||||
removeItem={removeItem}
|
removeItem={removeItem}
|
||||||
moveItem={moveItem}
|
|
||||||
itemTemplates={itemTemplates}
|
itemTemplates={itemTemplates}
|
||||||
showItemTemplateMenu={showItemTemplateMenu}
|
showItemTemplateMenu={showItemTemplateMenu}
|
||||||
setShowItemTemplateMenu={setShowItemTemplateMenu}
|
setShowItemTemplateMenu={setShowItemTemplateMenu}
|
||||||
|
|||||||
@@ -52,3 +52,13 @@
|
|||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
padding-left: 2.75rem;
|
padding-left: 2.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.admin-permission-item {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-permission-item .admin-form-checkbox {
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
132
src/admin/utils/__tests__/attendanceHelpers.test.js
Normal file
132
src/admin/utils/__tests__/attendanceHelpers.test.js
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import {
|
||||||
|
formatDate,
|
||||||
|
formatTime,
|
||||||
|
formatDatetime,
|
||||||
|
formatMinutes,
|
||||||
|
calculateWorkMinutes,
|
||||||
|
getLeaveTypeName,
|
||||||
|
getLeaveTypeBadgeClass
|
||||||
|
} from '../attendanceHelpers'
|
||||||
|
|
||||||
|
describe('formatDate', () => {
|
||||||
|
it('formatuje datum do cs-CZ formatu', () => {
|
||||||
|
const result = formatDate('2026-03-12')
|
||||||
|
expect(result).toMatch(/12/)
|
||||||
|
expect(result).toMatch(/2026/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('vraci pomlcku pro falsy hodnoty', () => {
|
||||||
|
expect(formatDate('')).toBe('—')
|
||||||
|
expect(formatDate(null)).toBe('—')
|
||||||
|
expect(formatDate(undefined)).toBe('—')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('formatTime', () => {
|
||||||
|
it('formatuje cas ve formatu HH:MM', () => {
|
||||||
|
const result = formatTime('2026-03-12T14:30:00')
|
||||||
|
expect(result).toBe('14:30')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('vraci pomlcku pro prazdny vstup', () => {
|
||||||
|
expect(formatTime('')).toBe('—')
|
||||||
|
expect(formatTime(null)).toBe('—')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('formatDatetime', () => {
|
||||||
|
it('formatuje datum a cas dohromady', () => {
|
||||||
|
const result = formatDatetime('2026-03-12T14:30:00')
|
||||||
|
expect(result).toContain('12')
|
||||||
|
expect(result).toContain('14:30')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('vraci pomlcku pro prazdny vstup', () => {
|
||||||
|
expect(formatDatetime('')).toBe('—')
|
||||||
|
expect(formatDatetime(null)).toBe('—')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('formatMinutes', () => {
|
||||||
|
it('formatuje minuty na H:MM', () => {
|
||||||
|
expect(formatMinutes(90)).toBe('1:30')
|
||||||
|
expect(formatMinutes(60)).toBe('1:00')
|
||||||
|
expect(formatMinutes(0)).toBe('0:00')
|
||||||
|
expect(formatMinutes(125)).toBe('2:05')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formatuje s jednotkou kdyz withUnit=true', () => {
|
||||||
|
expect(formatMinutes(90, true)).toBe('1:30 h')
|
||||||
|
expect(formatMinutes(0, true)).toBe('0:00 h')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formatuje male hodnoty s paddingem', () => {
|
||||||
|
expect(formatMinutes(5)).toBe('0:05')
|
||||||
|
expect(formatMinutes(1)).toBe('0:01')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('calculateWorkMinutes', () => {
|
||||||
|
it('spocita minuty bez prestavky', () => {
|
||||||
|
const record = {
|
||||||
|
arrival_time: '2026-03-12T08:00:00',
|
||||||
|
departure_time: '2026-03-12T16:00:00'
|
||||||
|
}
|
||||||
|
expect(calculateWorkMinutes(record)).toBe(480)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('odecte prestavku', () => {
|
||||||
|
const record = {
|
||||||
|
arrival_time: '2026-03-12T08:00:00',
|
||||||
|
departure_time: '2026-03-12T16:00:00',
|
||||||
|
break_start: '2026-03-12T12:00:00',
|
||||||
|
break_end: '2026-03-12T12:30:00'
|
||||||
|
}
|
||||||
|
expect(calculateWorkMinutes(record)).toBe(450)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('vraci 0 kdyz chybi prichod nebo odchod', () => {
|
||||||
|
expect(calculateWorkMinutes({ arrival_time: '2026-03-12T08:00:00' })).toBe(0)
|
||||||
|
expect(calculateWorkMinutes({ departure_time: '2026-03-12T16:00:00' })).toBe(0)
|
||||||
|
expect(calculateWorkMinutes({})).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('vraci 0 pro zaporne minuty', () => {
|
||||||
|
const record = {
|
||||||
|
arrival_time: '2026-03-12T16:00:00',
|
||||||
|
departure_time: '2026-03-12T08:00:00'
|
||||||
|
}
|
||||||
|
expect(calculateWorkMinutes(record)).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getLeaveTypeName', () => {
|
||||||
|
it('vraci spravne nazvy pro zname typy', () => {
|
||||||
|
expect(getLeaveTypeName('work')).toBe('Práce')
|
||||||
|
expect(getLeaveTypeName('vacation')).toBe('Dovolená')
|
||||||
|
expect(getLeaveTypeName('sick')).toBe('Nemoc')
|
||||||
|
expect(getLeaveTypeName('holiday')).toBe('Svátek')
|
||||||
|
expect(getLeaveTypeName('unpaid')).toBe('Neplacené volno')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('vraci Prace jako fallback', () => {
|
||||||
|
expect(getLeaveTypeName('unknown')).toBe('Práce')
|
||||||
|
expect(getLeaveTypeName(undefined)).toBe('Práce')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getLeaveTypeBadgeClass', () => {
|
||||||
|
it('vraci spravne CSS tridy', () => {
|
||||||
|
expect(getLeaveTypeBadgeClass('vacation')).toBe('badge-vacation')
|
||||||
|
expect(getLeaveTypeBadgeClass('sick')).toBe('badge-sick')
|
||||||
|
expect(getLeaveTypeBadgeClass('holiday')).toBe('badge-holiday')
|
||||||
|
expect(getLeaveTypeBadgeClass('unpaid')).toBe('badge-unpaid')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('vraci prazdny retezec pro work a nezname typy', () => {
|
||||||
|
expect(getLeaveTypeBadgeClass('work')).toBe('')
|
||||||
|
expect(getLeaveTypeBadgeClass('unknown')).toBe('')
|
||||||
|
expect(getLeaveTypeBadgeClass(undefined)).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
102
src/admin/utils/__tests__/formatters.test.js
Normal file
102
src/admin/utils/__tests__/formatters.test.js
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { formatCurrency, formatDate, formatKm, czechPlural } from '../formatters'
|
||||||
|
|
||||||
|
describe('formatCurrency', () => {
|
||||||
|
it('formatuje CZK s dvema desetinnymi misty', () => {
|
||||||
|
const result = formatCurrency(1234.5, 'CZK')
|
||||||
|
expect(result).toContain('Kč')
|
||||||
|
expect(result).toMatch(/1[\s\u00a0]?234,50/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formatuje EUR s eurem za castkou', () => {
|
||||||
|
const result = formatCurrency(99.9, 'EUR')
|
||||||
|
expect(result).toContain('€')
|
||||||
|
expect(result).toContain('99,90')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formatuje USD s dolarem pred castkou', () => {
|
||||||
|
const result = formatCurrency(1500, 'USD')
|
||||||
|
expect(result).toMatch(/^\$/)
|
||||||
|
expect(result).toContain('1,500.00')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formatuje GBP s librou pred castkou', () => {
|
||||||
|
const result = formatCurrency(250, 'GBP')
|
||||||
|
expect(result).toMatch(/^£/)
|
||||||
|
expect(result).toContain('250.00')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pouzije fallback pro neznámou menu', () => {
|
||||||
|
const result = formatCurrency(100, 'CHF')
|
||||||
|
expect(result).toBe('100.00 CHF')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('vraci 0 pro nevalidni vstup', () => {
|
||||||
|
const result = formatCurrency('abc', 'CZK')
|
||||||
|
expect(result).toContain('0,00')
|
||||||
|
expect(result).toContain('Kč')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('vraci 0 pro null', () => {
|
||||||
|
const result = formatCurrency(null, 'CZK')
|
||||||
|
expect(result).toContain('0,00')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formatuje zaporne castky', () => {
|
||||||
|
const result = formatCurrency(-500, 'CZK')
|
||||||
|
expect(result).toContain('Kč')
|
||||||
|
expect(result).toContain('500')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('formatDate', () => {
|
||||||
|
it('formatuje datum do cs-CZ formatu', () => {
|
||||||
|
const result = formatDate('2026-03-12')
|
||||||
|
// cs-CZ format: 12. 3. 2026 nebo 12.3.2026
|
||||||
|
expect(result).toMatch(/12/)
|
||||||
|
expect(result).toMatch(/3/)
|
||||||
|
expect(result).toMatch(/2026/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('vraci pomlcku pro prazdny vstup', () => {
|
||||||
|
expect(formatDate('')).toBe('—')
|
||||||
|
expect(formatDate(null)).toBe('—')
|
||||||
|
expect(formatDate(undefined)).toBe('—')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('formatKm', () => {
|
||||||
|
it('formatuje kilometry s oddelovacem tisicu', () => {
|
||||||
|
const result = formatKm(12345)
|
||||||
|
// cs-CZ pouziva mezeru nebo narrow no-break space jako oddelovac
|
||||||
|
expect(result).toMatch(/12[\s\u00a0]?345/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('vraci 0 pro nevalidni vstup', () => {
|
||||||
|
expect(formatKm('abc')).toBe('0')
|
||||||
|
expect(formatKm(null)).toBe('0')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formatuje mala cisla bez oddelovace', () => {
|
||||||
|
expect(formatKm(42)).toBe('42')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('czechPlural', () => {
|
||||||
|
it('vraci tvar pro 1', () => {
|
||||||
|
expect(czechPlural(1, 'den', 'dny', 'dní')).toBe('den')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('vraci tvar pro 2-4', () => {
|
||||||
|
expect(czechPlural(2, 'den', 'dny', 'dní')).toBe('dny')
|
||||||
|
expect(czechPlural(3, 'den', 'dny', 'dní')).toBe('dny')
|
||||||
|
expect(czechPlural(4, 'den', 'dny', 'dní')).toBe('dny')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('vraci tvar pro 0 a 5+', () => {
|
||||||
|
expect(czechPlural(0, 'den', 'dny', 'dní')).toBe('dní')
|
||||||
|
expect(czechPlural(5, 'den', 'dny', 'dní')).toBe('dní')
|
||||||
|
expect(czechPlural(10, 'den', 'dny', 'dní')).toBe('dní')
|
||||||
|
expect(czechPlural(100, 'den', 'dny', 'dní')).toBe('dní')
|
||||||
|
})
|
||||||
|
})
|
||||||
1
src/test/setup.js
Normal file
1
src/test/setup.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import '@testing-library/jest-dom'
|
||||||
@@ -58,6 +58,11 @@ import { defineConfig } from 'vite'
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), copyFoldersPlugin()],
|
plugins: [react(), copyFoldersPlugin()],
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'jsdom',
|
||||||
|
setupFiles: './src/test/setup.js',
|
||||||
|
},
|
||||||
build: {
|
build: {
|
||||||
outDir: 'dist',
|
outDir: 'dist',
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user