Compare commits

...

7 Commits

Author SHA1 Message Date
21f08593e4 feat: P7 FormField komponenta + migrace Vehicles
- FormField.jsx: wrapper pro label + input + error pattern (aria-invalid podpora)
- Vehicles.jsx: migrovano na FormField (demonstrace patternu)
- Postupna migrace dalsich formularovych stranek v budoucnu

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 19:02:54 +01:00
f88ae25057 feat: P6 operacni viditelnost - audit log prohlizec, cleanup script
- audit-log.php: API endpoint s filtrovanim (akce, entita, datum, hledani) a stranovanim
- AuditLog.jsx: stranka s tabulkou, filtry, pagination, skeleton loading
- Sidebar: polozka "Audit log" pod Systemem (settings.audit permission)
- cleanup.php: CLI script - maze rate limit soubory >24h a audit log >90 dni
- Migrace: settings.audit permission

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 19:01:33 +01:00
ec44895f3d feat: P5 UX polish - 404 stranka, ErrorBoundary, focus trap, ARIA
- NotFound.jsx: 404 stranka misto redirectu na / (lazy-loaded)
- ErrorBoundary: CSS tridy misto inline stylu, DEV error stack, odkaz na Dashboard
- useFocusTrap hook: Tab/Shift+Tab cycling, auto-focus, restore focus on close
- ConfirmModal: focus trap integrovan
- admin-error-stack CSS pro DEV chybovy vypis

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 18:46:22 +01:00
758be819c3 feat: P4 backend kvalita - SELECT * fix, overdue konsolidace, Validator
- SELECT * nahrazen explicitnimi sloupci ve 22 PHP souborech (69+ vyskytu)
- users-handlers.php: password_hash explicitne vyloucen z dotazu
- Overdue detekce presunuta do invoices.php routeru (1x pred dispatch misto 3x v handlerech)
- Validator.php: validacni helper s pravidly required, string, int, email, in, numeric
- PaginationHelper: PHPStan typy opraveny

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 18:42:42 +01:00
df506dfea4 refactor: P3 dekompozice velkych komponent
Dashboard.jsx (1346 -> 378 LOC):
- DashKpiCards, DashQuickActions, DashActivityFeed, DashAttendanceToday, DashProfile, DashSessions
- dashboardHelpers.js (konstanty + helper funkce)

OfferDetail.jsx (1061 -> ~530 LOC):
- useOfferForm hook (form state, draft, items/sections, submit)
- OfferCustomerPicker (customer search/select dropdown)

AttendanceAdmin.jsx (1036 -> ~275 LOC):
- useAttendanceAdmin hook (data fetching, filters, CRUD, print)
- AttendanceShiftTable (shift records table)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 18:22:38 +01:00
6863c7c557 feat: P2 strankovani - PaginationHelper, Pagination komponenta, integrace do 4 modulu
- PaginationHelper.php: parseParams() + paginate() - DRY backend pagination logika
- Pagination.jsx: frontend strankovaci komponenta (prev/next/cisla/info)
- CSS: .admin-pagination styly v admin.css
- Refaktor handleru: offers, orders, invoices, projects pouzivaji PaginationHelper
- Default 25 zaznamu na stranku (misto 500), max 500
- Frontend: page state + reset na search/filter zmenu
- useListData: pagination data v response

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 18:07:29 +01:00
6ad20ea04e feat: P1 quick wins - useDebounce hook, DB indexy, odstraneni TCPDF
- useDebounce hook (300ms) integrovan do useListData pro debounce hledani
- useListData rozsiren o page/perPage/pagination parametry (priprava pro P2)
- Migracni SQL s indexy na attendance, invoices, quotations, refresh_tokens, audit_log
- Odstranen nepouzivany TCPDF z composer.json
- Vite build plugin: graceful handling kdyz composer neni v PATH

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 17:57:02 +01:00
65 changed files with 4089 additions and 2631 deletions

62
api/admin/audit-log.php Normal file
View File

@@ -0,0 +1,62 @@
<?php
/**
* Audit Log API - prohlížení audit logu
*
* GET /api/admin/audit-log.php
* ?page=1&per_page=50&search=&action=&entity_type=&date_from=&date_to=
*/
declare(strict_types=1);
require_once dirname(__DIR__) . '/config.php';
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
require_once dirname(__DIR__) . '/includes/AuditLog.php';
setCorsHeaders();
setSecurityHeaders();
setNoCacheHeaders();
header('Content-Type: application/json; charset=utf-8');
$authData = JWTAuth::requireAuth();
AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(204);
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
errorResponse('Method not allowed', 405);
}
requirePermission($authData, 'settings.audit');
$page = max(1, (int) ($_GET['page'] ?? 1));
$perPage = max(1, min(100, (int) ($_GET['per_page'] ?? 50)));
$filters = [];
if (!empty($_GET['search'])) {
$filters['search'] = (string) $_GET['search'];
}
if (!empty($_GET['action'])) {
$filters['action'] = (string) $_GET['action'];
}
if (!empty($_GET['entity_type'])) {
$filters['entity_type'] = (string) $_GET['entity_type'];
}
if (!empty($_GET['date_from'])) {
$filters['date_from'] = (string) $_GET['date_from'];
}
if (!empty($_GET['date_to'])) {
$filters['date_to'] = (string) $_GET['date_to'];
}
$result = AuditLog::getLogs($filters, $page, $perPage);
successResponse($result);

View File

@@ -7,7 +7,11 @@ function handleGetCurrent(PDO $pdo, int $userId): void
$today = date('Y-m-d'); $today = date('Y-m-d');
$stmt = $pdo->prepare(" $stmt = $pdo->prepare("
SELECT * FROM attendance SELECT id, user_id, shift_date, arrival_time, arrival_lat, arrival_lng,
arrival_accuracy, arrival_address, break_start, break_end,
departure_time, departure_lat, departure_lng, departure_accuracy,
departure_address, notes, project_id, leave_type, leave_hours, created_at
FROM attendance
WHERE user_id = ? AND departure_time IS NULL AND (leave_type IS NULL OR leave_type = 'work') WHERE user_id = ? AND departure_time IS NULL AND (leave_type IS NULL OR leave_type = 'work')
ORDER BY created_at DESC LIMIT 1 ORDER BY created_at DESC LIMIT 1
"); ");
@@ -17,7 +21,10 @@ function handleGetCurrent(PDO $pdo, int $userId): void
$projectLogs = []; $projectLogs = [];
$activeProjectId = null; $activeProjectId = null;
if ($ongoingShift) { if ($ongoingShift) {
$stmt = $pdo->prepare('SELECT * FROM attendance_project_logs WHERE attendance_id = ? ORDER BY started_at ASC'); $stmt = $pdo->prepare(
'SELECT id, attendance_id, project_id, started_at, ended_at, hours, minutes
FROM attendance_project_logs WHERE attendance_id = ? ORDER BY started_at ASC'
);
$stmt->execute([$ongoingShift['id']]); $stmt->execute([$ongoingShift['id']]);
$projectLogs = $stmt->fetchAll(); $projectLogs = $stmt->fetchAll();
foreach ($projectLogs as $log) { foreach ($projectLogs as $log) {
@@ -29,7 +36,11 @@ function handleGetCurrent(PDO $pdo, int $userId): void
} }
$stmt = $pdo->prepare(" $stmt = $pdo->prepare("
SELECT * FROM attendance SELECT id, user_id, shift_date, arrival_time, arrival_lat, arrival_lng,
arrival_accuracy, arrival_address, break_start, break_end,
departure_time, departure_lat, departure_lng, departure_accuracy,
departure_address, notes, project_id, leave_type, leave_hours, created_at
FROM attendance
WHERE user_id = ? AND shift_date = ? WHERE user_id = ? AND shift_date = ?
AND departure_time IS NOT NULL AND departure_time IS NOT NULL
AND (leave_type IS NULL OR leave_type = 'work') AND (leave_type IS NULL OR leave_type = 'work')
@@ -43,7 +54,8 @@ function handleGetCurrent(PDO $pdo, int $userId): void
if (!empty($completedShiftIds)) { if (!empty($completedShiftIds)) {
$placeholders = implode(',', array_fill(0, count($completedShiftIds), '?')); $placeholders = implode(',', array_fill(0, count($completedShiftIds), '?'));
$stmt = $pdo->prepare( $stmt = $pdo->prepare(
"SELECT * FROM attendance_project_logs "SELECT id, attendance_id, project_id, started_at, ended_at, hours, minutes
FROM attendance_project_logs
WHERE attendance_id IN ($placeholders) WHERE attendance_id IN ($placeholders)
ORDER BY started_at ASC" ORDER BY started_at ASC"
); );
@@ -65,7 +77,9 @@ function handleGetCurrent(PDO $pdo, int $userId): void
$endDate = date('Y-m-t'); $endDate = date('Y-m-t');
$stmt = $pdo->prepare(' $stmt = $pdo->prepare('
SELECT * FROM attendance SELECT id, user_id, shift_date, arrival_time, break_start, break_end,
departure_time, notes, project_id, leave_type, leave_hours
FROM attendance
WHERE user_id = ? AND shift_date BETWEEN ? AND ? WHERE user_id = ? AND shift_date BETWEEN ? AND ?
'); ');
$stmt->execute([$userId, $startDate, $endDate]); $stmt->execute([$userId, $startDate, $endDate]);
@@ -167,7 +181,10 @@ function handleGetHistory(PDO $pdo, int $userId): void
$endDate = date('Y-m-t', strtotime($startDate)); $endDate = date('Y-m-t', strtotime($startDate));
$stmt = $pdo->prepare(' $stmt = $pdo->prepare('
SELECT * FROM attendance SELECT id, user_id, shift_date, arrival_time, arrival_address,
break_start, break_end, departure_time, departure_address,
notes, project_id, leave_type, leave_hours, created_at
FROM attendance
WHERE user_id = ? AND shift_date BETWEEN ? AND ? WHERE user_id = ? AND shift_date BETWEEN ? AND ?
ORDER BY shift_date DESC ORDER BY shift_date DESC
'); ');
@@ -245,7 +262,9 @@ function handlePunch(PDO $pdo, int $userId): void
$address = !empty($input['address']) ? $input['address'] : null; $address = !empty($input['address']) ? $input['address'] : null;
$stmt = $pdo->prepare(" $stmt = $pdo->prepare("
SELECT * FROM attendance SELECT id, user_id, shift_date, arrival_time, break_start, break_end,
departure_time, notes, project_id, leave_type, created_at
FROM attendance
WHERE user_id = ? AND departure_time IS NULL AND (leave_type IS NULL OR leave_type = 'work') WHERE user_id = ? AND departure_time IS NULL AND (leave_type IS NULL OR leave_type = 'work')
ORDER BY created_at DESC LIMIT 1 ORDER BY created_at DESC LIMIT 1
"); ");
@@ -529,7 +548,10 @@ function handleGetProjectLogs(PDO $pdo, int $currentUserId, array $authData): vo
} }
} }
$stmt = $pdo->prepare('SELECT * FROM attendance_project_logs WHERE attendance_id = ? ORDER BY started_at ASC'); $stmt = $pdo->prepare(
'SELECT id, attendance_id, project_id, started_at, ended_at, hours, minutes
FROM attendance_project_logs WHERE attendance_id = ? ORDER BY started_at ASC'
);
$stmt->execute([$attendanceId]); $stmt->execute([$attendanceId]);
$logs = $stmt->fetchAll(); $logs = $stmt->fetchAll();
@@ -556,7 +578,7 @@ function handleSaveProjectLogs(PDO $pdo): void
errorResponse('attendance_id je povinné'); errorResponse('attendance_id je povinné');
} }
$stmt = $pdo->prepare('SELECT * FROM attendance WHERE id = ?'); $stmt = $pdo->prepare('SELECT id FROM attendance WHERE id = ?');
$stmt->execute([$attendanceId]); $stmt->execute([$attendanceId]);
$record = $stmt->fetch(); $record = $stmt->fetch();
if (!$record) { if (!$record) {

View File

@@ -4,7 +4,11 @@ declare(strict_types=1);
function handleGetBankAccountList(PDO $pdo): void function handleGetBankAccountList(PDO $pdo): void
{ {
$stmt = $pdo->query('SELECT * FROM bank_accounts ORDER BY position, id'); $stmt = $pdo->query(
'SELECT id, account_name, bank_name, account_number, iban, bic,
currency, is_default, position, created_at, modified_at
FROM bank_accounts ORDER BY position, id'
);
successResponse($stmt->fetchAll()); successResponse($stmt->fetchAll());
} }
@@ -78,7 +82,11 @@ function handleCreateBankAccount(PDO $pdo): void
function handleUpdateBankAccount(PDO $pdo, int $id): void function handleUpdateBankAccount(PDO $pdo, int $id): void
{ {
$stmt = $pdo->prepare('SELECT * FROM bank_accounts WHERE id = ?'); $stmt = $pdo->prepare(
'SELECT id, account_name, bank_name, account_number, iban, bic,
currency, is_default, position
FROM bank_accounts WHERE id = ?'
);
$stmt->execute([$id]); $stmt->execute([$id]);
$account = $stmt->fetch(); $account = $stmt->fetch();
@@ -145,7 +153,7 @@ function handleUpdateBankAccount(PDO $pdo, int $id): void
function handleDeleteBankAccount(PDO $pdo, int $id): void function handleDeleteBankAccount(PDO $pdo, int $id): void
{ {
$stmt = $pdo->prepare('SELECT * FROM bank_accounts WHERE id = ?'); $stmt = $pdo->prepare('SELECT id, account_name FROM bank_accounts WHERE id = ?');
$stmt->execute([$id]); $stmt->execute([$id]);
$account = $stmt->fetch(); $account = $stmt->fetch();

View File

@@ -9,7 +9,13 @@ declare(strict_types=1);
function getOrCreateSettings(PDO $pdo, bool $includeLogo = false): array function getOrCreateSettings(PDO $pdo, bool $includeLogo = false): array
{ {
if ($includeLogo) { if ($includeLogo) {
$stmt = $pdo->query('SELECT * FROM company_settings LIMIT 1'); $stmt = $pdo->query('
SELECT id, company_name, company_id, vat_id, street, city, postal_code,
country, quotation_prefix, default_currency, default_vat_rate,
custom_fields, logo_data, uuid, modified_at, sync_version,
order_type_code, invoice_type_code, is_deleted, require_2fa
FROM company_settings LIMIT 1
');
} else { } else {
$stmt = $pdo->query(' $stmt = $pdo->query('
SELECT id, company_name, company_id, vat_id, street, city, postal_code, country, SELECT id, company_name, company_id, vat_id, street, city, postal_code, country,

View File

@@ -54,7 +54,9 @@ function encodeCustomerCustomFields(array $input, ?string $existingJson): ?strin
function handleGetAll(PDO $pdo): void function handleGetAll(PDO $pdo): void
{ {
$stmt = $pdo->query(' $stmt = $pdo->query('
SELECT c.*, COUNT(q.id) as quotation_count SELECT c.id, c.name, c.street, c.city, c.postal_code, c.country,
c.company_id, c.vat_id, c.custom_fields, c.created_at,
COUNT(q.id) as quotation_count
FROM customers c FROM customers c
LEFT JOIN quotations q ON q.customer_id = c.id LEFT JOIN quotations q ON q.customer_id = c.id
GROUP BY c.id GROUP BY c.id
@@ -72,7 +74,11 @@ function handleGetAll(PDO $pdo): void
function handleGetOne(PDO $pdo, int $id): void function handleGetOne(PDO $pdo, int $id): void
{ {
$stmt = $pdo->prepare('SELECT * FROM customers WHERE id = ?'); $stmt = $pdo->prepare(
'SELECT id, name, street, city, postal_code, country,
company_id, vat_id, custom_fields, created_at
FROM customers WHERE id = ?'
);
$stmt->execute([$id]); $stmt->execute([$id]);
$customer = $stmt->fetch(); $customer = $stmt->fetch();
@@ -93,7 +99,9 @@ function handleSearch(PDO $pdo): void
} }
$stmt = $pdo->prepare(' $stmt = $pdo->prepare('
SELECT * FROM customers SELECT id, name, street, city, postal_code, country,
company_id, vat_id, custom_fields
FROM customers
WHERE name LIKE ? OR company_id LIKE ? OR city LIKE ? WHERE name LIKE ? OR company_id LIKE ? OR city LIKE ?
ORDER BY name ASC ORDER BY name ASC
LIMIT 20 LIMIT 20
@@ -177,7 +185,11 @@ function handleCreateCustomer(PDO $pdo): void
function handleUpdateCustomer(PDO $pdo, int $id): void function handleUpdateCustomer(PDO $pdo, int $id): void
{ {
$stmt = $pdo->prepare('SELECT * FROM customers WHERE id = ?'); $stmt = $pdo->prepare(
'SELECT id, name, street, city, postal_code, country,
company_id, vat_id, custom_fields
FROM customers WHERE id = ?'
);
$stmt->execute([$id]); $stmt->execute([$id]);
$existing = $stmt->fetch(); $existing = $stmt->fetch();
@@ -248,7 +260,7 @@ function handleUpdateCustomer(PDO $pdo, int $id): void
function handleDeleteCustomer(PDO $pdo, int $id): void function handleDeleteCustomer(PDO $pdo, int $id): void
{ {
$stmt = $pdo->prepare('SELECT * FROM customers WHERE id = ?'); $stmt = $pdo->prepare('SELECT id, name FROM customers WHERE id = ?');
$stmt->execute([$id]); $stmt->execute([$id]);
$customer = $stmt->fetch(); $customer = $stmt->fetch();

View File

@@ -99,9 +99,6 @@ function handleGetStats(PDO $pdo): void
$month = max(1, min(12, (int) ($_GET['month'] ?? (int) date('n')))); $month = max(1, min(12, (int) ($_GET['month'] ?? (int) date('n'))));
$year = max(2020, min(2099, (int) ($_GET['year'] ?? (int) date('Y')))); $year = max(2020, min(2099, (int) ($_GET['year'] ?? (int) date('Y'))));
// Lazy overdue detekce
$pdo->exec("UPDATE invoices SET status = 'overdue' WHERE status = 'issued' AND due_date < CURDATE()");
$monthStart = sprintf('%04d-%02d-01', $year, $month); $monthStart = sprintf('%04d-%02d-01', $year, $month);
$monthEnd = date('Y-m-t', strtotime($monthStart)); $monthEnd = date('Y-m-t', strtotime($monthStart));
@@ -169,12 +166,7 @@ function handleGetStats(PDO $pdo): void
function handleGetList(PDO $pdo): void function handleGetList(PDO $pdo): void
{ {
$search = trim($_GET['search'] ?? '');
$statusFilter = trim($_GET['status'] ?? ''); $statusFilter = trim($_GET['status'] ?? '');
$sort = $_GET['sort'] ?? 'created_at';
$order = strtoupper($_GET['order'] ?? 'DESC') === 'ASC' ? 'ASC' : 'DESC';
$page = max(1, (int) ($_GET['page'] ?? 1));
$perPage = min(500, max(1, (int) ($_GET['per_page'] ?? 500)));
$sortMap = [ $sortMap = [
'InvoiceNumber' => 'i.invoice_number', 'InvoiceNumber' => 'i.invoice_number',
@@ -188,21 +180,15 @@ function handleGetList(PDO $pdo): void
'IssueDate' => 'i.issue_date', 'IssueDate' => 'i.issue_date',
'issue_date' => 'i.issue_date', 'issue_date' => 'i.issue_date',
]; ];
if (!isset($sortMap[$sort])) {
errorResponse('Neplatný parametr řazení', 400);
}
$sortCol = $sortMap[$sort];
// Lazy overdue detekce $p = PaginationHelper::parseParams($sortMap);
$pdo->exec("UPDATE invoices SET status = 'overdue' WHERE status = 'issued' AND due_date < CURDATE()");
$where = 'WHERE 1=1'; $where = 'WHERE 1=1';
$params = []; $params = [];
if ($search) { if ($p['search']) {
$search = mb_substr($search, 0, 100);
$where .= ' AND (i.invoice_number LIKE ? OR c.name LIKE ? OR c.company_id LIKE ?)'; $where .= ' AND (i.invoice_number LIKE ? OR c.name LIKE ? OR c.company_id LIKE ?)';
$searchParam = "%{$search}%"; $searchParam = "%{$p['search']}%";
$params = array_merge($params, [$searchParam, $searchParam, $searchParam]); $params = array_merge($params, [$searchParam, $searchParam, $searchParam]);
} }
@@ -215,36 +201,26 @@ function handleGetList(PDO $pdo): void
} }
} }
$countSql = " $from = "FROM invoices i
SELECT COUNT(*)
FROM invoices i
LEFT JOIN customers c ON i.customer_id = c.id LEFT JOIN customers c ON i.customer_id = c.id
$where LEFT JOIN orders o ON i.order_id = o.id";
";
$stmt = $pdo->prepare($countSql);
$stmt->execute($params);
$total = (int) $stmt->fetchColumn();
$offset = ($page - 1) * $perPage; $result = PaginationHelper::paginate(
$pdo,
$sql = " "SELECT COUNT(*) FROM invoices i LEFT JOIN customers c ON i.customer_id = c.id {$where}",
SELECT i.id, i.invoice_number, i.order_id, i.status, i.currency, "SELECT i.id, i.invoice_number, i.order_id, i.status, i.currency,
i.issue_date, i.due_date, i.paid_date, i.created_at, i.apply_vat, i.issue_date, i.due_date, i.paid_date, i.created_at, i.apply_vat,
c.name as customer_name, c.name as customer_name,
(SELECT COALESCE(SUM(ii.quantity * ii.unit_price), 0) (SELECT COALESCE(SUM(ii.quantity * ii.unit_price), 0)
FROM invoice_items ii WHERE ii.invoice_id = i.id) as subtotal, FROM invoice_items ii WHERE ii.invoice_id = i.id) as subtotal,
o.order_number o.order_number
FROM invoices i {$from} {$where}
LEFT JOIN customers c ON i.customer_id = c.id ORDER BY {$p['sort']} {$p['order']}",
LEFT JOIN orders o ON i.order_id = o.id $params,
$where $p
ORDER BY $sortCol $order );
LIMIT $perPage OFFSET $offset
";
$stmt = $pdo->prepare($sql); $invoices = $result['items'];
$stmt->execute($params);
$invoices = $stmt->fetchAll();
// Dopocitat celkovou castku s DPH // Dopocitat celkovou castku s DPH
foreach ($invoices as &$inv) { foreach ($invoices as &$inv) {
@@ -265,21 +241,20 @@ function handleGetList(PDO $pdo): void
successResponse([ successResponse([
'invoices' => $invoices, 'invoices' => $invoices,
'total' => $total, 'pagination' => $result['pagination'],
'page' => $page,
'per_page' => $perPage,
]); ]);
} }
function handleGetDetail(PDO $pdo, int $id): void function handleGetDetail(PDO $pdo, int $id): void
{ {
// Lazy overdue
$pdo->prepare(
"UPDATE invoices SET status = 'overdue' WHERE id = ? AND status = 'issued' AND due_date < CURDATE()"
)->execute([$id]);
$stmt = $pdo->prepare(' $stmt = $pdo->prepare('
SELECT i.*, c.name as customer_name, o.order_number SELECT i.id, i.invoice_number, i.order_id, i.customer_id, i.status,
i.currency, i.vat_rate, i.apply_vat, i.payment_method,
i.constant_symbol, i.bank_name, i.bank_swift, i.bank_iban,
i.bank_account, i.issue_date, i.due_date, i.tax_date,
i.paid_date, i.issued_by, i.notes, i.internal_notes,
i.created_at, i.modified_at,
c.name as customer_name, o.order_number
FROM invoices i FROM invoices i
LEFT JOIN customers c ON i.customer_id = c.id LEFT JOIN customers c ON i.customer_id = c.id
LEFT JOIN orders o ON i.order_id = o.id LEFT JOIN orders o ON i.order_id = o.id
@@ -293,7 +268,10 @@ function handleGetDetail(PDO $pdo, int $id): void
} }
// Polozky // Polozky
$stmt = $pdo->prepare('SELECT * FROM invoice_items WHERE invoice_id = ? ORDER BY position'); $stmt = $pdo->prepare(
'SELECT id, invoice_id, description, quantity, unit, unit_price, vat_rate, position
FROM invoice_items WHERE invoice_id = ? ORDER BY position'
);
$stmt->execute([$id]); $stmt->execute([$id]);
$invoice['items'] = $stmt->fetchAll(); $invoice['items'] = $stmt->fetchAll();
@@ -337,7 +315,11 @@ function handleGetOrderData(PDO $pdo, int $id): void
} }
// Polozky objednavky // Polozky objednavky
$stmt = $pdo->prepare('SELECT * FROM order_items WHERE order_id = ? ORDER BY position'); $stmt = $pdo->prepare(
'SELECT id, order_id, description, item_description, quantity, unit,
unit_price, is_included_in_total, position
FROM order_items WHERE order_id = ? ORDER BY position'
);
$stmt->execute([$id]); $stmt->execute([$id]);
$order['items'] = $stmt->fetchAll(); $order['items'] = $stmt->fetchAll();
@@ -526,7 +508,14 @@ function handleCreateInvoice(PDO $pdo, array $authData): void
function handleUpdateInvoice(PDO $pdo, int $id): void function handleUpdateInvoice(PDO $pdo, int $id): void
{ {
$stmt = $pdo->prepare('SELECT * FROM invoices WHERE id = ?'); $stmt = $pdo->prepare(
'SELECT id, invoice_number, order_id, customer_id, status, currency,
vat_rate, apply_vat, payment_method, constant_symbol,
bank_name, bank_swift, bank_iban, bank_account,
issue_date, due_date, tax_date, paid_date,
issued_by, notes, internal_notes
FROM invoices WHERE id = ?'
);
$stmt->execute([$id]); $stmt->execute([$id]);
$invoice = $stmt->fetch(); $invoice = $stmt->fetch();
@@ -677,7 +666,9 @@ function handleUpdateInvoice(PDO $pdo, int $id): void
function handleDeleteInvoice(PDO $pdo, int $id): void function handleDeleteInvoice(PDO $pdo, int $id): void
{ {
$stmt = $pdo->prepare('SELECT * FROM invoices WHERE id = ?'); $stmt = $pdo->prepare(
'SELECT id, invoice_number, customer_id FROM invoices WHERE id = ?'
);
$stmt->execute([$id]); $stmt->execute([$id]);
$invoice = $stmt->fetch(); $invoice = $stmt->fetch();

View File

@@ -31,7 +31,10 @@ function getLeaveBalanceForRequest(PDO $pdo, int $userId, ?int $year = null): ar
{ {
$year = $year ?: (int)date('Y'); $year = $year ?: (int)date('Y');
$stmt = $pdo->prepare('SELECT * FROM leave_balances WHERE user_id = ? AND year = ?'); $stmt = $pdo->prepare(
'SELECT id, user_id, year, vacation_total, vacation_used, sick_used
FROM leave_balances WHERE user_id = ? AND year = ?'
);
$stmt->execute([$userId, $year]); $stmt->execute([$userId, $year]);
$balance = $stmt->fetch(); $balance = $stmt->fetch();
@@ -77,7 +80,9 @@ function getPendingVacationHours(PDO $pdo, int $userId, int $year): float
function handleGetMyRequests(PDO $pdo, int $userId): void function handleGetMyRequests(PDO $pdo, int $userId): void
{ {
$stmt = $pdo->prepare(" $stmt = $pdo->prepare("
SELECT lr.*, SELECT lr.id, lr.user_id, lr.leave_type, lr.date_from, lr.date_to,
lr.total_hours, lr.total_days, lr.notes, lr.status,
lr.reviewer_id, lr.reviewer_note, lr.reviewed_at, lr.created_at,
CONCAT(u.first_name, ' ', u.last_name) as reviewer_name CONCAT(u.first_name, ' ', u.last_name) as reviewer_name
FROM leave_requests lr FROM leave_requests lr
LEFT JOIN users u ON lr.reviewer_id = u.id LEFT JOIN users u ON lr.reviewer_id = u.id
@@ -96,7 +101,9 @@ function handleGetMyRequests(PDO $pdo, int $userId): void
function handleGetPending(PDO $pdo): void function handleGetPending(PDO $pdo): void
{ {
$stmt = $pdo->prepare(" $stmt = $pdo->prepare("
SELECT lr.*, SELECT lr.id, lr.user_id, lr.leave_type, lr.date_from, lr.date_to,
lr.total_hours, lr.total_days, lr.notes, lr.status,
lr.reviewer_id, lr.reviewer_note, lr.reviewed_at, lr.created_at,
CONCAT(u.first_name, ' ', u.last_name) as employee_name, CONCAT(u.first_name, ' ', u.last_name) as employee_name,
CONCAT(rv.first_name, ' ', rv.last_name) as reviewer_name CONCAT(rv.first_name, ' ', rv.last_name) as reviewer_name
FROM leave_requests lr FROM leave_requests lr
@@ -138,7 +145,9 @@ function handleGetAll(PDO $pdo): void
$whereClause = $where ? 'WHERE ' . implode(' AND ', $where) : ''; $whereClause = $where ? 'WHERE ' . implode(' AND ', $where) : '';
$stmt = $pdo->prepare(" $stmt = $pdo->prepare("
SELECT lr.*, SELECT lr.id, lr.user_id, lr.leave_type, lr.date_from, lr.date_to,
lr.total_hours, lr.total_days, lr.notes, lr.status,
lr.reviewer_id, lr.reviewer_note, lr.reviewed_at, lr.created_at,
CONCAT(u.first_name, ' ', u.last_name) as employee_name, CONCAT(u.first_name, ' ', u.last_name) as employee_name,
CONCAT(rv.first_name, ' ', rv.last_name) as reviewer_name CONCAT(rv.first_name, ' ', rv.last_name) as reviewer_name
FROM leave_requests lr FROM leave_requests lr
@@ -270,7 +279,11 @@ function handleCancelRequest(PDO $pdo, int $userId): void
errorResponse('ID žádosti je povinné'); errorResponse('ID žádosti je povinné');
} }
$stmt = $pdo->prepare('SELECT * FROM leave_requests WHERE id = ? AND user_id = ?'); $stmt = $pdo->prepare(
'SELECT id, user_id, leave_type, date_from, date_to, total_hours,
total_days, notes, status
FROM leave_requests WHERE id = ? AND user_id = ?'
);
$stmt->execute([$requestId, $userId]); $stmt->execute([$requestId, $userId]);
$request = $stmt->fetch(); $request = $stmt->fetch();
@@ -310,7 +323,11 @@ function handleApproveRequest(PDO $pdo, int $reviewerId, array $authData): void
errorResponse('ID žádosti je povinné'); errorResponse('ID žádosti je povinné');
} }
$stmt = $pdo->prepare('SELECT * FROM leave_requests WHERE id = ?'); $stmt = $pdo->prepare(
'SELECT id, user_id, leave_type, date_from, date_to, total_hours,
total_days, status
FROM leave_requests WHERE id = ?'
);
$stmt->execute([$requestId]); $stmt->execute([$requestId]);
$request = $stmt->fetch(); $request = $stmt->fetch();
@@ -427,7 +444,9 @@ function handleRejectRequest(PDO $pdo, int $reviewerId, array $authData): void
errorResponse('Důvod zamítnutí je povinný'); errorResponse('Důvod zamítnutí je povinný');
} }
$stmt = $pdo->prepare('SELECT * FROM leave_requests WHERE id = ?'); $stmt = $pdo->prepare(
'SELECT id, user_id, status FROM leave_requests WHERE id = ?'
);
$stmt->execute([$requestId]); $stmt->execute([$requestId]);
$request = $stmt->fetch(); $request = $stmt->fetch();

View File

@@ -4,12 +4,6 @@ declare(strict_types=1);
function handleGetList(PDO $pdo): void function handleGetList(PDO $pdo): void
{ {
$search = trim($_GET['search'] ?? '');
$sort = $_GET['sort'] ?? 'created_at';
$order = strtoupper($_GET['order'] ?? 'DESC') === 'ASC' ? 'ASC' : 'DESC';
$page = max(1, (int) ($_GET['page'] ?? 1));
$perPage = min(500, max(1, (int) ($_GET['per_page'] ?? 500)));
$sortMap = [ $sortMap = [
'Date' => 'q.created_at', 'Date' => 'q.created_at',
'CreatedAt' => 'q.created_at', 'CreatedAt' => 'q.created_at',
@@ -23,64 +17,48 @@ function handleGetList(PDO $pdo): void
'Currency' => 'q.currency', 'Currency' => 'q.currency',
'currency' => 'q.currency', 'currency' => 'q.currency',
]; ];
if (!isset($sortMap[$sort])) {
errorResponse('Neplatný parametr řazení', 400);
}
$sortCol = $sortMap[$sort];
$p = PaginationHelper::parseParams($sortMap);
$where = 'WHERE 1=1'; $where = 'WHERE 1=1';
$params = []; $params = [];
if ($search) { if ($p['search']) {
$search = mb_substr($search, 0, 100);
$where .= ' AND (q.quotation_number LIKE ? OR q.project_code LIKE ? OR c.name LIKE ?)'; $where .= ' AND (q.quotation_number LIKE ? OR q.project_code LIKE ? OR c.name LIKE ?)';
$searchParam = "%{$search}%"; $searchParam = "%{$p['search']}%";
$params = [$searchParam, $searchParam, $searchParam]; $params = [$searchParam, $searchParam, $searchParam];
} }
// Celkovy pocet pro pagination $from = "FROM quotations q LEFT JOIN customers c ON q.customer_id = c.id";
$countSql = "
SELECT COUNT(*)
FROM quotations q
LEFT JOIN customers c ON q.customer_id = c.id
$where
";
$stmt = $pdo->prepare($countSql);
$stmt->execute($params);
$total = (int) $stmt->fetchColumn();
$offset = ($page - 1) * $perPage; $result = PaginationHelper::paginate(
$pdo,
$sql = " "SELECT COUNT(*) {$from} {$where}",
SELECT q.id, q.quotation_number, q.project_code, q.created_at, q.valid_until, "SELECT q.id, q.quotation_number, q.project_code, q.created_at, q.valid_until,
q.currency, q.language, q.apply_vat, q.vat_rate, q.exchange_rate, q.currency, q.language, q.apply_vat, q.vat_rate, q.exchange_rate,
q.customer_id, q.order_id, q.status, q.customer_id, q.order_id, q.status,
c.name as customer_name, c.name as customer_name,
(SELECT COALESCE(SUM(CASE WHEN qi.is_included_in_total THEN qi.quantity * qi.unit_price ELSE 0 END), 0) (SELECT COALESCE(SUM(CASE WHEN qi.is_included_in_total THEN qi.quantity * qi.unit_price ELSE 0 END), 0)
FROM quotation_items qi WHERE qi.quotation_id = q.id) as total FROM quotation_items qi WHERE qi.quotation_id = q.id) as total
FROM quotations q {$from} {$where}
LEFT JOIN customers c ON q.customer_id = c.id ORDER BY {$p['sort']} {$p['order']}",
$where $params,
ORDER BY $sortCol $order $p
LIMIT $perPage OFFSET $offset );
";
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$quotations = $stmt->fetchAll();
successResponse([ successResponse([
'quotations' => $quotations, 'quotations' => $result['items'],
'total' => $total, 'pagination' => $result['pagination'],
'page' => $page,
'per_page' => $perPage,
]); ]);
} }
function handleGetDetail(PDO $pdo, int $id): void function handleGetDetail(PDO $pdo, int $id): void
{ {
$stmt = $pdo->prepare(' $stmt = $pdo->prepare('
SELECT q.*, c.name as customer_name SELECT q.id, q.quotation_number, q.project_code, q.customer_id,
q.created_at, q.valid_until, q.currency, q.language,
q.vat_rate, q.apply_vat, q.exchange_rate, q.order_id,
q.status, q.scope_title, q.scope_description,
c.name as customer_name
FROM quotations q FROM quotations q
LEFT JOIN customers c ON q.customer_id = c.id LEFT JOIN customers c ON q.customer_id = c.id
WHERE q.id = ? WHERE q.id = ?
@@ -94,7 +72,9 @@ function handleGetDetail(PDO $pdo, int $id): void
// Get items // Get items
$stmt = $pdo->prepare(' $stmt = $pdo->prepare('
SELECT * FROM quotation_items SELECT id, quotation_id, position, description, item_description,
quantity, unit, unit_price, is_included_in_total
FROM quotation_items
WHERE quotation_id = ? WHERE quotation_id = ?
ORDER BY position ORDER BY position
'); ');
@@ -103,7 +83,8 @@ function handleGetDetail(PDO $pdo, int $id): void
// Get scope sections // Get scope sections
$stmt = $pdo->prepare(' $stmt = $pdo->prepare('
SELECT * FROM scope_sections SELECT id, quotation_id, position, title, title_cz, content
FROM scope_sections
WHERE quotation_id = ? WHERE quotation_id = ?
ORDER BY position ORDER BY position
'); ');
@@ -290,7 +271,12 @@ function handleCreateOffer(PDO $pdo): void
function handleUpdateOffer(PDO $pdo, int $id): void function handleUpdateOffer(PDO $pdo, int $id): void
{ {
$stmt = $pdo->prepare('SELECT * FROM quotations WHERE id = ?'); $stmt = $pdo->prepare(
'SELECT id, quotation_number, project_code, customer_id, created_at,
valid_until, currency, language, vat_rate, apply_vat,
exchange_rate, order_id, status, scope_title, scope_description
FROM quotations WHERE id = ?'
);
$stmt->execute([$id]); $stmt->execute([$id]);
$existing = $stmt->fetch(); $existing = $stmt->fetch();
@@ -375,7 +361,12 @@ function handleUpdateOffer(PDO $pdo, int $id): void
function handleDuplicate(PDO $pdo, int $sourceId): void function handleDuplicate(PDO $pdo, int $sourceId): void
{ {
$stmt = $pdo->prepare('SELECT * FROM quotations WHERE id = ?'); $stmt = $pdo->prepare(
'SELECT id, quotation_number, project_code, customer_id, currency,
language, vat_rate, apply_vat, exchange_rate,
scope_title, scope_description
FROM quotations WHERE id = ?'
);
$stmt->execute([$sourceId]); $stmt->execute([$sourceId]);
$source = $stmt->fetch(); $source = $stmt->fetch();
@@ -383,11 +374,18 @@ function handleDuplicate(PDO $pdo, int $sourceId): void
errorResponse('Zdrojová nabídka nebyla nalezena', 404); errorResponse('Zdrojová nabídka nebyla nalezena', 404);
} }
$stmt = $pdo->prepare('SELECT * FROM quotation_items WHERE quotation_id = ? ORDER BY position'); $stmt = $pdo->prepare(
'SELECT description, item_description, quantity, unit, unit_price,
is_included_in_total, position
FROM quotation_items WHERE quotation_id = ? ORDER BY position'
);
$stmt->execute([$sourceId]); $stmt->execute([$sourceId]);
$sourceItems = $stmt->fetchAll(); $sourceItems = $stmt->fetchAll();
$stmt = $pdo->prepare('SELECT * FROM scope_sections WHERE quotation_id = ? ORDER BY position'); $stmt = $pdo->prepare(
'SELECT title, title_cz, content, position
FROM scope_sections WHERE quotation_id = ? ORDER BY position'
);
$stmt->execute([$sourceId]); $stmt->execute([$sourceId]);
$sourceSections = $stmt->fetchAll(); $sourceSections = $stmt->fetchAll();

View File

@@ -4,7 +4,10 @@ declare(strict_types=1);
function handleGetItemTemplates(PDO $pdo): void function handleGetItemTemplates(PDO $pdo): void
{ {
$stmt = $pdo->query('SELECT * FROM item_templates ORDER BY category, name'); $stmt = $pdo->query(
'SELECT id, name, description, default_price, category
FROM item_templates ORDER BY category, name'
);
successResponse(['templates' => $stmt->fetchAll()]); successResponse(['templates' => $stmt->fetchAll()]);
} }
@@ -100,13 +103,17 @@ function handleDeleteItemTemplate(PDO $pdo, int $id): void
function handleGetScopeTemplates(PDO $pdo): void function handleGetScopeTemplates(PDO $pdo): void
{ {
$stmt = $pdo->query('SELECT * FROM scope_templates ORDER BY name'); $stmt = $pdo->query(
'SELECT id, name, title, description FROM scope_templates ORDER BY name'
);
successResponse(['templates' => $stmt->fetchAll()]); successResponse(['templates' => $stmt->fetchAll()]);
} }
function handleGetScopeDetail(PDO $pdo, int $id): void function handleGetScopeDetail(PDO $pdo, int $id): void
{ {
$stmt = $pdo->prepare('SELECT * FROM scope_templates WHERE id = ?'); $stmt = $pdo->prepare(
'SELECT id, name, title, description FROM scope_templates WHERE id = ?'
);
$stmt->execute([$id]); $stmt->execute([$id]);
$template = $stmt->fetch(); $template = $stmt->fetch();
@@ -114,7 +121,10 @@ function handleGetScopeDetail(PDO $pdo, int $id): void
errorResponse('Šablona nebyla nalezena', 404); errorResponse('Šablona nebyla nalezena', 404);
} }
$stmt = $pdo->prepare('SELECT * FROM scope_template_sections WHERE scope_template_id = ? ORDER BY position'); $stmt = $pdo->prepare(
'SELECT id, scope_template_id, position, title, title_cz, content
FROM scope_template_sections WHERE scope_template_id = ? ORDER BY position'
);
$stmt->execute([$id]); $stmt->execute([$id]);
$template['sections'] = $stmt->fetchAll(); $template['sections'] = $stmt->fetchAll();

View File

@@ -25,12 +25,6 @@ function generateOrderNumber(PDO $pdo): string
function handleGetList(PDO $pdo): void function handleGetList(PDO $pdo): void
{ {
$search = trim($_GET['search'] ?? '');
$sort = $_GET['sort'] ?? 'created_at';
$order = strtoupper($_GET['order'] ?? 'DESC') === 'ASC' ? 'ASC' : 'DESC';
$page = max(1, (int) ($_GET['page'] ?? 1));
$perPage = min(500, max(1, (int) ($_GET['per_page'] ?? 500)));
$sortMap = [ $sortMap = [
'OrderNumber' => 'o.order_number', 'OrderNumber' => 'o.order_number',
'order_number' => 'o.order_number', 'order_number' => 'o.order_number',
@@ -41,36 +35,25 @@ function handleGetList(PDO $pdo): void
'Currency' => 'o.currency', 'Currency' => 'o.currency',
'currency' => 'o.currency', 'currency' => 'o.currency',
]; ];
if (!isset($sortMap[$sort])) {
errorResponse('Neplatný parametr řazení', 400);
}
$sortCol = $sortMap[$sort];
$p = PaginationHelper::parseParams($sortMap);
$where = 'WHERE 1=1'; $where = 'WHERE 1=1';
$params = []; $params = [];
if ($search) { if ($p['search']) {
$search = mb_substr($search, 0, 100);
$where .= ' AND (o.order_number LIKE ? OR q.quotation_number LIKE ? OR q.project_code LIKE ? OR c.name LIKE ?)'; $where .= ' AND (o.order_number LIKE ? OR q.quotation_number LIKE ? OR q.project_code LIKE ? OR c.name LIKE ?)';
$searchParam = "%{$search}%"; $searchParam = "%{$p['search']}%";
$params = [$searchParam, $searchParam, $searchParam, $searchParam]; $params = [$searchParam, $searchParam, $searchParam, $searchParam];
} }
$countSql = " $from = "FROM orders o
SELECT COUNT(*)
FROM orders o
LEFT JOIN quotations q ON o.quotation_id = q.id LEFT JOIN quotations q ON o.quotation_id = q.id
LEFT JOIN customers c ON o.customer_id = c.id LEFT JOIN customers c ON o.customer_id = c.id";
$where
";
$stmt = $pdo->prepare($countSql);
$stmt->execute($params);
$total = (int) $stmt->fetchColumn();
$offset = ($page - 1) * $perPage; $result = PaginationHelper::paginate(
$pdo,
$sql = " "SELECT COUNT(*) {$from} {$where}",
SELECT o.id, o.order_number, o.quotation_id, o.status, o.currency, "SELECT o.id, o.order_number, o.quotation_id, o.status, o.currency,
o.created_at, o.apply_vat, o.vat_rate, o.created_at, o.apply_vat, o.vat_rate,
q.quotation_number, q.project_code, q.quotation_number, q.project_code,
c.name as customer_name, c.name as customer_name,
@@ -78,23 +61,15 @@ function handleGetList(PDO $pdo): void
FROM order_items oi WHERE oi.order_id = o.id) as total, FROM order_items oi WHERE oi.order_id = o.id) as total,
(SELECT inv.id FROM invoices inv WHERE inv.order_id = o.id LIMIT 1) as invoice_id, (SELECT inv.id FROM invoices inv WHERE inv.order_id = o.id LIMIT 1) as invoice_id,
(SELECT inv.invoice_number FROM invoices inv WHERE inv.order_id = o.id LIMIT 1) as invoice_number (SELECT inv.invoice_number FROM invoices inv WHERE inv.order_id = o.id LIMIT 1) as invoice_number
FROM orders o {$from} {$where}
LEFT JOIN quotations q ON o.quotation_id = q.id ORDER BY {$p['sort']} {$p['order']}",
LEFT JOIN customers c ON o.customer_id = c.id $params,
$where $p
ORDER BY $sortCol $order );
LIMIT $perPage OFFSET $offset
";
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$orders = $stmt->fetchAll();
successResponse([ successResponse([
'orders' => $orders, 'orders' => $result['items'],
'total' => $total, 'pagination' => $result['pagination'],
'page' => $page,
'per_page' => $perPage,
]); ]);
} }
@@ -121,12 +96,19 @@ function handleGetDetail(PDO $pdo, int $id): void
} }
// Get items // Get items
$stmt = $pdo->prepare('SELECT * FROM order_items WHERE order_id = ? ORDER BY position'); $stmt = $pdo->prepare(
'SELECT id, order_id, description, item_description, quantity, unit,
unit_price, is_included_in_total, position
FROM order_items WHERE order_id = ? ORDER BY position'
);
$stmt->execute([$id]); $stmt->execute([$id]);
$order['items'] = $stmt->fetchAll(); $order['items'] = $stmt->fetchAll();
// Get sections // Get sections
$stmt = $pdo->prepare('SELECT * FROM order_sections WHERE order_id = ? ORDER BY position'); $stmt = $pdo->prepare(
'SELECT id, order_id, title, title_cz, content, position
FROM order_sections WHERE order_id = ? ORDER BY position'
);
$stmt->execute([$id]); $stmt->execute([$id]);
$order['sections'] = $stmt->fetchAll(); $order['sections'] = $stmt->fetchAll();
@@ -227,7 +209,12 @@ function handleCreateOrder(PDO $pdo): void
} }
// Verify quotation exists and has no order yet // Verify quotation exists and has no order yet
$stmt = $pdo->prepare('SELECT * FROM quotations WHERE id = ?'); $stmt = $pdo->prepare(
'SELECT id, quotation_number, project_code, customer_id, currency,
language, vat_rate, apply_vat, exchange_rate, order_id,
scope_title, scope_description
FROM quotations WHERE id = ?'
);
$stmt->execute([$quotationId]); $stmt->execute([$quotationId]);
$quotation = $stmt->fetch(); $quotation = $stmt->fetch();
@@ -240,11 +227,18 @@ function handleCreateOrder(PDO $pdo): void
} }
// Get quotation items and sections // Get quotation items and sections
$stmt = $pdo->prepare('SELECT * FROM quotation_items WHERE quotation_id = ? ORDER BY position'); $stmt = $pdo->prepare(
'SELECT description, item_description, quantity, unit,
unit_price, is_included_in_total, position
FROM quotation_items WHERE quotation_id = ? ORDER BY position'
);
$stmt->execute([$quotationId]); $stmt->execute([$quotationId]);
$quotationItems = $stmt->fetchAll(); $quotationItems = $stmt->fetchAll();
$stmt = $pdo->prepare('SELECT * FROM scope_sections WHERE quotation_id = ? ORDER BY position'); $stmt = $pdo->prepare(
'SELECT title, title_cz, content, position
FROM scope_sections WHERE quotation_id = ? ORDER BY position'
);
$stmt->execute([$quotationId]); $stmt->execute([$quotationId]);
$quotationSections = $stmt->fetchAll(); $quotationSections = $stmt->fetchAll();
@@ -379,7 +373,9 @@ function handleCreateOrder(PDO $pdo): void
function handleUpdateOrder(PDO $pdo, int $id): void function handleUpdateOrder(PDO $pdo, int $id): void
{ {
$stmt = $pdo->prepare('SELECT * FROM orders WHERE id = ?'); $stmt = $pdo->prepare(
'SELECT id, order_number, status, notes FROM orders WHERE id = ?'
);
$stmt->execute([$id]); $stmt->execute([$id]);
$order = $stmt->fetch(); $order = $stmt->fetch();
@@ -486,7 +482,9 @@ function handleUpdateOrder(PDO $pdo, int $id): void
function handleDeleteOrder(PDO $pdo, int $id): void function handleDeleteOrder(PDO $pdo, int $id): void
{ {
$stmt = $pdo->prepare('SELECT * FROM orders WHERE id = ?'); $stmt = $pdo->prepare(
'SELECT id, order_number, quotation_id FROM orders WHERE id = ?'
);
$stmt->execute([$id]); $stmt->execute([$id]);
$order = $stmt->fetch(); $order = $stmt->fetch();

View File

@@ -114,7 +114,9 @@ function handleCreateProject(PDO $pdo): void
function handleDeleteProject(PDO $pdo, int $id): void function handleDeleteProject(PDO $pdo, int $id): void
{ {
$stmt = $pdo->prepare('SELECT * FROM projects WHERE id = ?'); $stmt = $pdo->prepare(
'SELECT id, project_number, name, order_id, status FROM projects WHERE id = ?'
);
$stmt->execute([$id]); $stmt->execute([$id]);
$project = $stmt->fetch(); $project = $stmt->fetch();
@@ -156,12 +158,6 @@ function handleDeleteProject(PDO $pdo, int $id): void
function handleGetList(PDO $pdo): void function handleGetList(PDO $pdo): void
{ {
$search = trim($_GET['search'] ?? '');
$sort = $_GET['sort'] ?? 'created_at';
$order = strtoupper($_GET['order'] ?? 'DESC') === 'ASC' ? 'ASC' : 'DESC';
$page = max(1, (int) ($_GET['page'] ?? 1));
$perPage = min(500, max(1, (int) ($_GET['per_page'] ?? 500)));
$sortMap = [ $sortMap = [
'ProjectNumber' => 'p.project_number', 'ProjectNumber' => 'p.project_number',
'project_number' => 'p.project_number', 'project_number' => 'p.project_number',
@@ -176,63 +172,47 @@ function handleGetList(PDO $pdo): void
'CreatedAt' => 'p.created_at', 'CreatedAt' => 'p.created_at',
'created_at' => 'p.created_at', 'created_at' => 'p.created_at',
]; ];
if (!isset($sortMap[$sort])) {
errorResponse('Neplatný parametr řazení', 400);
}
$sortCol = $sortMap[$sort];
$p = PaginationHelper::parseParams($sortMap);
$where = 'WHERE 1=1'; $where = 'WHERE 1=1';
$params = []; $params = [];
if ($search) { if ($p['search']) {
$search = mb_substr($search, 0, 100);
$where .= ' AND (p.project_number LIKE ? OR p.name LIKE ? OR c.name LIKE ?)'; $where .= ' AND (p.project_number LIKE ? OR p.name LIKE ? OR c.name LIKE ?)';
$searchParam = "%{$search}%"; $searchParam = "%{$p['search']}%";
$params = [$searchParam, $searchParam, $searchParam]; $params = [$searchParam, $searchParam, $searchParam];
} }
$countSql = " $from = "FROM projects p
SELECT COUNT(*)
FROM projects p
LEFT JOIN customers c ON p.customer_id = c.id LEFT JOIN customers c ON p.customer_id = c.id
LEFT JOIN orders o ON p.order_id = o.id LEFT JOIN orders o ON p.order_id = o.id";
$where
";
$stmt = $pdo->prepare($countSql);
$stmt->execute($params);
$total = (int) $stmt->fetchColumn();
$offset = ($page - 1) * $perPage; $result = PaginationHelper::paginate(
$pdo,
$sql = " "SELECT COUNT(*) {$from} {$where}",
SELECT p.id, p.project_number, p.name, p.status, p.start_date, p.end_date, "SELECT p.id, p.project_number, p.name, p.status, p.start_date, p.end_date,
p.order_id, p.quotation_id, p.created_at, p.order_id, p.quotation_id, p.created_at,
c.name as customer_name, c.name as customer_name,
o.order_number o.order_number
FROM projects p {$from} {$where}
LEFT JOIN customers c ON p.customer_id = c.id ORDER BY {$p['sort']} {$p['order']}",
LEFT JOIN orders o ON p.order_id = o.id $params,
$where $p
ORDER BY $sortCol $order );
LIMIT $perPage OFFSET $offset
";
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$projects = $stmt->fetchAll();
successResponse([ successResponse([
'projects' => $projects, 'projects' => $result['items'],
'total' => $total, 'pagination' => $result['pagination'],
'page' => $page,
'per_page' => $perPage,
]); ]);
} }
function handleGetDetail(PDO $pdo, int $id): void function handleGetDetail(PDO $pdo, int $id): void
{ {
$stmt = $pdo->prepare(' $stmt = $pdo->prepare('
SELECT p.*, SELECT p.id, p.project_number, p.name, p.customer_id,
p.quotation_id, p.order_id, p.status,
p.start_date, p.end_date, p.notes,
p.created_at, p.modified_at,
c.name as customer_name, c.name as customer_name,
o.order_number, o.status as order_status, o.order_number, o.status as order_status,
q.quotation_number q.quotation_number
@@ -254,7 +234,10 @@ function handleGetDetail(PDO $pdo, int $id): void
function handleUpdateProject(PDO $pdo, int $id): void function handleUpdateProject(PDO $pdo, int $id): void
{ {
$stmt = $pdo->prepare('SELECT * FROM projects WHERE id = ?'); $stmt = $pdo->prepare(
'SELECT id, project_number, name, status, start_date, end_date, notes
FROM projects WHERE id = ?'
);
$stmt->execute([$id]); $stmt->execute([$id]);
$project = $stmt->fetch(); $project = $stmt->fetch();

View File

@@ -358,7 +358,12 @@ function handleBulkUpload(PDO $pdo, array $authData): void
function handleUpdateReceivedInvoice(PDO $pdo, int $id): void function handleUpdateReceivedInvoice(PDO $pdo, int $id): void
{ {
$stmt = $pdo->prepare('SELECT * FROM received_invoices WHERE id = ?'); $stmt = $pdo->prepare(
'SELECT id, supplier_name, invoice_number, description,
amount, currency, vat_rate, vat_amount,
issue_date, due_date, paid_date, status, notes
FROM received_invoices WHERE id = ?'
);
$stmt->execute([$id]); $stmt->execute([$id]);
$invoice = $stmt->fetch(); $invoice = $stmt->fetch();

View File

@@ -9,7 +9,8 @@ function handleGetRole(PDO $pdo): void
{ {
// Get all roles with user count (LEFT JOIN instead of correlated subquery) // Get all roles with user count (LEFT JOIN instead of correlated subquery)
$stmt = $pdo->query(' $stmt = $pdo->query('
SELECT r.*, COUNT(u.id) as user_count SELECT r.id, r.name, r.display_name, r.description, r.created_at,
COUNT(u.id) as user_count
FROM roles r FROM roles r
LEFT JOIN users u ON u.role_id = r.id LEFT JOIN users u ON u.role_id = r.id
GROUP BY r.id GROUP BY r.id
@@ -133,7 +134,9 @@ function handleCreateRole(PDO $pdo): void
function handleUpdateRole(PDO $pdo, int $roleId): void function handleUpdateRole(PDO $pdo, int $roleId): void
{ {
// Get existing role // Get existing role
$stmt = $pdo->prepare('SELECT * FROM roles WHERE id = ?'); $stmt = $pdo->prepare(
'SELECT id, name, display_name, description FROM roles WHERE id = ?'
);
$stmt->execute([$roleId]); $stmt->execute([$roleId]);
$role = $stmt->fetch(); $role = $stmt->fetch();
@@ -205,7 +208,9 @@ function handleUpdateRole(PDO $pdo, int $roleId): void
*/ */
function handleDeleteRole(PDO $pdo, int $roleId): void function handleDeleteRole(PDO $pdo, int $roleId): void
{ {
$stmt = $pdo->prepare('SELECT * FROM roles WHERE id = ?'); $stmt = $pdo->prepare(
'SELECT id, name, display_name, description FROM roles WHERE id = ?'
);
$stmt->execute([$roleId]); $stmt->execute([$roleId]);
$role = $stmt->fetch(); $role = $stmt->fetch();

View File

@@ -181,7 +181,9 @@ function handleVerify(PDO $pdo, TwoFactorAuth $tfa): void
$userId = $tokenData['user_id']; $userId = $tokenData['user_id'];
$stmt = $pdo->prepare(' $stmt = $pdo->prepare('
SELECT u.*, r.name as role_name, r.display_name as role_display_name SELECT u.id, u.username, u.email, u.first_name, u.last_name,
u.role_id, u.is_active, u.totp_secret, u.totp_enabled,
r.name as role_name, r.display_name as role_display_name
FROM users u FROM users u
LEFT JOIN roles r ON u.role_id = r.id LEFT JOIN roles r ON u.role_id = r.id
WHERE u.id = ? AND u.totp_enabled = 1 WHERE u.id = ? AND u.totp_enabled = 1
@@ -230,7 +232,9 @@ function handleBackupVerify(PDO $pdo): void
$userId = $tokenData['user_id']; $userId = $tokenData['user_id'];
$stmt = $pdo->prepare(' $stmt = $pdo->prepare('
SELECT u.*, r.name as role_name, r.display_name as role_display_name SELECT u.id, u.username, u.email, u.first_name, u.last_name,
u.role_id, u.is_active, u.totp_enabled, u.totp_backup_codes,
r.name as role_name, r.display_name as role_display_name
FROM users u FROM users u
LEFT JOIN roles r ON u.role_id = r.id LEFT JOIN roles r ON u.role_id = r.id
WHERE u.id = ? AND u.totp_enabled = 1 WHERE u.id = ? AND u.totp_enabled = 1
@@ -355,7 +359,8 @@ function verifyLoginToken(PDO $pdo, string $token): ?array
$hashedToken = hash('sha256', $token); $hashedToken = hash('sha256', $token);
$stmt = $pdo->prepare(' $stmt = $pdo->prepare('
SELECT * FROM totp_login_tokens SELECT id, user_id, token_hash, expires_at, created_at
FROM totp_login_tokens
WHERE token_hash = ? AND expires_at > NOW() WHERE token_hash = ? AND expires_at > NOW()
'); ');
$stmt->execute([$hashedToken]); $stmt->execute([$hashedToken]);

View File

@@ -37,7 +37,10 @@ function handleGetCurrent(PDO $pdo, int $userId): void
$endDate = date('Y-m-t', strtotime($startDate)); $endDate = date('Y-m-t', strtotime($startDate));
$sql = " $sql = "
SELECT t.*, v.spz, v.name as vehicle_name, v.brand, v.model, SELECT t.id, t.vehicle_id, t.user_id, t.trip_date, t.start_km,
t.end_km, t.distance, t.route_from, t.route_to,
t.is_business, t.notes, t.created_at,
v.spz, v.name as vehicle_name, v.brand, v.model,
CONCAT(u.first_name, ' ', u.last_name) as driver_name CONCAT(u.first_name, ' ', u.last_name) as driver_name
FROM trips t FROM trips t
JOIN vehicles v ON t.vehicle_id = v.id JOIN vehicles v ON t.vehicle_id = v.id
@@ -101,7 +104,10 @@ function handleGetHistory(PDO $pdo, int $userId): void
$endDate = date('Y-m-t', strtotime($startDate)); $endDate = date('Y-m-t', strtotime($startDate));
$sql = " $sql = "
SELECT t.*, v.spz, v.name as vehicle_name, v.brand, v.model, SELECT t.id, t.vehicle_id, t.user_id, t.trip_date, t.start_km,
t.end_km, t.distance, t.route_from, t.route_to,
t.is_business, t.notes, t.created_at,
v.spz, v.name as vehicle_name, v.brand, v.model,
CONCAT(u.first_name, ' ', u.last_name) as driver_name CONCAT(u.first_name, ' ', u.last_name) as driver_name
FROM trips t FROM trips t
JOIN vehicles v ON t.vehicle_id = v.id JOIN vehicles v ON t.vehicle_id = v.id
@@ -173,7 +179,10 @@ function handleGetAdmin(PDO $pdo): void
} }
$sql = " $sql = "
SELECT t.*, v.spz, v.name as vehicle_name, SELECT t.id, t.vehicle_id, t.user_id, t.trip_date, t.start_km,
t.end_km, t.distance, t.route_from, t.route_to,
t.is_business, t.notes, t.created_at,
v.spz, v.name as vehicle_name,
CONCAT(u.first_name, ' ', u.last_name) as driver_name CONCAT(u.first_name, ' ', u.last_name) as driver_name
FROM trips t FROM trips t
JOIN vehicles v ON t.vehicle_id = v.id JOIN vehicles v ON t.vehicle_id = v.id
@@ -239,7 +248,10 @@ function handleGetAdmin(PDO $pdo): void
function handleGetVehicles(PDO $pdo): void function handleGetVehicles(PDO $pdo): void
{ {
$stmt = $pdo->query(' $stmt = $pdo->query('
SELECT v.*, COUNT(t.id) as trip_count, SELECT v.id, v.spz, v.name, v.brand, v.model,
v.initial_km, v.actual_km, v.is_active,
v.created_at, v.updated_at,
COUNT(t.id) as trip_count,
COALESCE(MAX(t.end_km), v.initial_km) as current_km COALESCE(MAX(t.end_km), v.initial_km) as current_km
FROM vehicles v FROM vehicles v
LEFT JOIN trips t ON t.vehicle_id = v.id LEFT JOIN trips t ON t.vehicle_id = v.id
@@ -415,7 +427,11 @@ function handleVehicle(PDO $pdo): void
*/ */
function handleUpdateTrip(PDO $pdo, int $id, int $userId, array $authData): void function handleUpdateTrip(PDO $pdo, int $id, int $userId, array $authData): void
{ {
$stmt = $pdo->prepare('SELECT * FROM trips WHERE id = ?'); $stmt = $pdo->prepare(
'SELECT id, vehicle_id, user_id, trip_date, start_km, end_km,
route_from, route_to, is_business, notes
FROM trips WHERE id = ?'
);
$stmt->execute([$id]); $stmt->execute([$id]);
$trip = $stmt->fetch(); $trip = $stmt->fetch();
@@ -467,7 +483,11 @@ function handleUpdateTrip(PDO $pdo, int $id, int $userId, array $authData): void
*/ */
function handleDeleteTrip(PDO $pdo, int $id, int $userId, array $authData): void function handleDeleteTrip(PDO $pdo, int $id, int $userId, array $authData): void
{ {
$stmt = $pdo->prepare('SELECT * FROM trips WHERE id = ?'); $stmt = $pdo->prepare(
'SELECT id, vehicle_id, user_id, trip_date, start_km, end_km,
route_from, route_to, is_business, notes
FROM trips WHERE id = ?'
);
$stmt->execute([$id]); $stmt->execute([$id]);
$trip = $stmt->fetch(); $trip = $stmt->fetch();
@@ -497,7 +517,9 @@ function handleDeleteVehicle(PDO $pdo, int $id): void
errorResponse('ID je povinné'); errorResponse('ID je povinné');
} }
$stmt = $pdo->prepare('SELECT * FROM vehicles WHERE id = ?'); $stmt = $pdo->prepare(
'SELECT id, spz, name, brand, model, is_active FROM vehicles WHERE id = ?'
);
$stmt->execute([$id]); $stmt->execute([$id]);
$vehicle = $stmt->fetch(); $vehicle = $stmt->fetch();
@@ -573,7 +595,10 @@ function handleGetPrint(PDO $pdo): void
} }
$sql = " $sql = "
SELECT t.*, v.spz, v.name as vehicle_name, v.brand, v.model, SELECT t.id, t.vehicle_id, t.user_id, t.trip_date, t.start_km,
t.end_km, t.distance, t.route_from, t.route_to,
t.is_business, t.notes, t.created_at,
v.spz, v.name as vehicle_name, v.brand, v.model,
CONCAT(u.first_name, ' ', u.last_name) as driver_name CONCAT(u.first_name, ' ', u.last_name) as driver_name
FROM trips t FROM trips t
JOIN vehicles v ON t.vehicle_id = v.id JOIN vehicles v ON t.vehicle_id = v.id

View File

@@ -142,7 +142,11 @@ function handleCreateUser(PDO $pdo, array $authData): void
function handleUpdateUser(PDO $pdo, int $userId, int $currentUserId, array $authData): void function handleUpdateUser(PDO $pdo, int $userId, int $currentUserId, array $authData): void
{ {
// Get existing user // Get existing user
$stmt = $pdo->prepare('SELECT * FROM users WHERE id = ?'); $stmt = $pdo->prepare('
SELECT id, username, email, first_name, last_name, role_id, is_active,
last_login, created_at
FROM users WHERE id = ?
');
$stmt->execute([$userId]); $stmt->execute([$userId]);
$existingUser = $stmt->fetch(); $existingUser = $stmt->fetch();

View File

@@ -43,7 +43,14 @@ $lang = in_array($_GET['lang'] ?? '', ['cs', 'en']) ? $_GET['lang'] : 'cs';
try { try {
$pdo = db(); $pdo = db();
$stmt = $pdo->prepare('SELECT * FROM invoices WHERE id = ?'); $stmt = $pdo->prepare(
'SELECT id, invoice_number, order_id, customer_id, status, currency,
vat_rate, apply_vat, payment_method, constant_symbol,
bank_name, bank_swift, bank_iban, bank_account,
issue_date, due_date, tax_date, paid_date,
issued_by, notes
FROM invoices WHERE id = ?'
);
$stmt->execute([$id]); $stmt->execute([$id]);
$invoice = $stmt->fetch(); $invoice = $stmt->fetch();
if (!$invoice) { if (!$invoice) {
@@ -52,20 +59,32 @@ try {
} }
// Polozky // Polozky
$stmt = $pdo->prepare('SELECT * FROM invoice_items WHERE invoice_id = ? ORDER BY position'); $stmt = $pdo->prepare(
'SELECT id, invoice_id, description, quantity, unit, unit_price, vat_rate, position
FROM invoice_items WHERE invoice_id = ? ORDER BY position'
);
$stmt->execute([$id]); $stmt->execute([$id]);
$items = $stmt->fetchAll(); $items = $stmt->fetchAll();
// Zakaznik // Zakaznik
$customer = null; $customer = null;
if ($invoice['customer_id']) { if ($invoice['customer_id']) {
$stmt = $pdo->prepare('SELECT * FROM customers WHERE id = ?'); $stmt = $pdo->prepare(
'SELECT id, name, street, city, postal_code, country,
company_id, vat_id, custom_fields
FROM customers WHERE id = ?'
);
$stmt->execute([$invoice['customer_id']]); $stmt->execute([$invoice['customer_id']]);
$customer = $stmt->fetch(); $customer = $stmt->fetch();
} }
// Firemni udaje // Firemni udaje
$stmt = $pdo->query('SELECT * FROM company_settings LIMIT 1'); $stmt = $pdo->query(
'SELECT id, company_name, company_id, vat_id, street, city,
postal_code, country, custom_fields, logo_data,
default_currency, default_vat_rate
FROM company_settings LIMIT 1'
);
$settings = $stmt->fetch(); $settings = $stmt->fetch();
// Logo // Logo

View File

@@ -19,6 +19,7 @@ require_once dirname(__DIR__) . '/config.php';
require_once dirname(__DIR__) . '/includes/JWTAuth.php'; require_once dirname(__DIR__) . '/includes/JWTAuth.php';
require_once dirname(__DIR__) . '/includes/AuditLog.php'; require_once dirname(__DIR__) . '/includes/AuditLog.php';
require_once dirname(__DIR__) . '/includes/CnbRates.php'; require_once dirname(__DIR__) . '/includes/CnbRates.php';
require_once dirname(__DIR__) . '/includes/PaginationHelper.php';
require_once __DIR__ . '/handlers/invoices-handlers.php'; require_once __DIR__ . '/handlers/invoices-handlers.php';
setCorsHeaders(); setCorsHeaders();
@@ -36,6 +37,11 @@ $id = isset($_GET['id']) ? (int) $_GET['id'] : null;
try { try {
$pdo = db(); $pdo = db();
// Overdue detekce - jednou pred dispatchem (misto v kazdem handleru)
if ($method === 'GET') {
$pdo->exec("UPDATE invoices SET status = 'overdue' WHERE status = 'issued' AND due_date < CURDATE()");
}
switch ($method) { switch ($method) {
case 'GET': case 'GET':
requirePermission($authData, 'invoices.view'); requirePermission($authData, 'invoices.view');

View File

@@ -38,7 +38,12 @@ if (!$id) {
try { try {
$pdo = db(); $pdo = db();
$stmt = $pdo->prepare('SELECT * FROM quotations WHERE id = ?'); $stmt = $pdo->prepare(
'SELECT id, quotation_number, project_code, customer_id, created_at,
valid_until, currency, language, vat_rate, apply_vat,
exchange_rate, scope_title, scope_description
FROM quotations WHERE id = ?'
);
$stmt->execute([$id]); $stmt->execute([$id]);
$quotation = $stmt->fetch(); $quotation = $stmt->fetch();
if (!$quotation) { if (!$quotation) {
@@ -48,20 +53,36 @@ try {
$customer = null; $customer = null;
if ($quotation['customer_id']) { if ($quotation['customer_id']) {
$stmt = $pdo->prepare('SELECT * FROM customers WHERE id = ?'); $stmt = $pdo->prepare(
'SELECT id, name, street, city, postal_code, country,
company_id, vat_id, custom_fields
FROM customers WHERE id = ?'
);
$stmt->execute([$quotation['customer_id']]); $stmt->execute([$quotation['customer_id']]);
$customer = $stmt->fetch(); $customer = $stmt->fetch();
} }
$stmt = $pdo->prepare('SELECT * FROM quotation_items WHERE quotation_id = ? ORDER BY position'); $stmt = $pdo->prepare(
'SELECT id, quotation_id, position, description, item_description,
quantity, unit, unit_price, is_included_in_total
FROM quotation_items WHERE quotation_id = ? ORDER BY position'
);
$stmt->execute([$id]); $stmt->execute([$id]);
$items = $stmt->fetchAll(); $items = $stmt->fetchAll();
$stmt = $pdo->prepare('SELECT * FROM scope_sections WHERE quotation_id = ? ORDER BY position'); $stmt = $pdo->prepare(
'SELECT id, quotation_id, position, title, title_cz, content
FROM scope_sections WHERE quotation_id = ? ORDER BY position'
);
$stmt->execute([$id]); $stmt->execute([$id]);
$sections = $stmt->fetchAll(); $sections = $stmt->fetchAll();
$stmt = $pdo->query('SELECT * FROM company_settings LIMIT 1'); $stmt = $pdo->query(
'SELECT id, company_name, company_id, vat_id, street, city,
postal_code, country, custom_fields, logo_data,
quotation_prefix, default_currency, default_vat_rate
FROM company_settings LIMIT 1'
);
$settings = $stmt->fetch(); $settings = $stmt->fetch();
$logoBase64 = ''; $logoBase64 = '';

View File

@@ -18,6 +18,7 @@ declare(strict_types=1);
require_once dirname(__DIR__) . '/config.php'; require_once dirname(__DIR__) . '/config.php';
require_once dirname(__DIR__) . '/includes/JWTAuth.php'; require_once dirname(__DIR__) . '/includes/JWTAuth.php';
require_once dirname(__DIR__) . '/includes/AuditLog.php'; require_once dirname(__DIR__) . '/includes/AuditLog.php';
require_once dirname(__DIR__) . '/includes/PaginationHelper.php';
require_once __DIR__ . '/handlers/offers-handlers.php'; require_once __DIR__ . '/handlers/offers-handlers.php';
setCorsHeaders(); setCorsHeaders();

View File

@@ -15,6 +15,7 @@ declare(strict_types=1);
require_once dirname(__DIR__) . '/config.php'; require_once dirname(__DIR__) . '/config.php';
require_once dirname(__DIR__) . '/includes/JWTAuth.php'; require_once dirname(__DIR__) . '/includes/JWTAuth.php';
require_once dirname(__DIR__) . '/includes/AuditLog.php'; require_once dirname(__DIR__) . '/includes/AuditLog.php';
require_once dirname(__DIR__) . '/includes/PaginationHelper.php';
require_once __DIR__ . '/handlers/orders-handlers.php'; require_once __DIR__ . '/handlers/orders-handlers.php';
setCorsHeaders(); setCorsHeaders();

View File

@@ -35,7 +35,11 @@ try {
$userId = $authData['user_id']; $userId = $authData['user_id'];
// Get existing user // Get existing user
$stmt = $pdo->prepare('SELECT * FROM users WHERE id = ?'); $stmt = $pdo->prepare('
SELECT id, username, email, first_name, last_name, role_id, is_active,
last_login, created_at
FROM users WHERE id = ?
');
$stmt->execute([$userId]); $stmt->execute([$userId]);
$existingUser = $stmt->fetch(); $existingUser = $stmt->fetch();

View File

@@ -18,6 +18,7 @@ declare(strict_types=1);
require_once dirname(__DIR__) . '/config.php'; require_once dirname(__DIR__) . '/config.php';
require_once dirname(__DIR__) . '/includes/JWTAuth.php'; require_once dirname(__DIR__) . '/includes/JWTAuth.php';
require_once dirname(__DIR__) . '/includes/AuditLog.php'; require_once dirname(__DIR__) . '/includes/AuditLog.php';
require_once dirname(__DIR__) . '/includes/PaginationHelper.php';
require_once __DIR__ . '/handlers/projects-handlers.php'; require_once __DIR__ . '/handlers/projects-handlers.php';
setCorsHeaders(); setCorsHeaders();

58
api/cleanup.php Normal file
View File

@@ -0,0 +1,58 @@
<?php
/**
* Cleanup CLI script - maze stare rate limit soubory a audit log zaznamy.
*
* Pouziti: php api/cleanup.php
* Doporuceny cron: 0 3 * * * php /path/to/api/cleanup.php
*/
declare(strict_types=1);
if (php_sapi_name() !== 'cli') {
http_response_code(403);
echo 'Pouze CLI';
exit(1);
}
require_once __DIR__ . '/config.php';
$rateLimitDir = __DIR__ . '/rate_limits';
$rateLimitMaxAge = 24 * 60 * 60; // 24 hodin
$auditLogMaxDays = 90;
$deleted = 0;
$errors = 0;
// Rate limit soubory starsi 24h
if (is_dir($rateLimitDir)) {
$now = time();
$files = glob($rateLimitDir . '/*.json');
if ($files !== false) {
foreach ($files as $file) {
$age = $now - filemtime($file);
if ($age > $rateLimitMaxAge) {
if (unlink($file)) {
$deleted++;
} else {
$errors++;
}
}
}
}
}
echo "Rate limits: smazano {$deleted} souboru" . ($errors > 0 ? " ({$errors} chyb)" : '') . "\n";
// Audit log zaznamy starsi 90 dni
try {
$pdo = db();
$stmt = $pdo->prepare(
'DELETE FROM audit_logs WHERE created_at < DATE_SUB(NOW(), INTERVAL ? DAY)'
);
$stmt->execute([$auditLogMaxDays]);
$auditDeleted = $stmt->rowCount();
echo "Audit log: smazano {$auditDeleted} zaznamu starsich {$auditLogMaxDays} dni\n";
} catch (PDOException $e) {
echo "Audit log: chyba - {$e->getMessage()}\n";
}

View File

@@ -17,7 +17,10 @@ function handleGetAdmin(PDO $pdo): void
$endDate = date('Y-m-t', strtotime($startDate)); $endDate = date('Y-m-t', strtotime($startDate));
$sql = " $sql = "
SELECT a.*, CONCAT(u.first_name, ' ', u.last_name) as user_name SELECT a.id, a.user_id, a.shift_date, a.arrival_time, a.arrival_address,
a.break_start, a.break_end, a.departure_time, a.departure_address,
a.notes, a.project_id, a.leave_type, a.leave_hours, a.created_at,
CONCAT(u.first_name, ' ', u.last_name) as user_name
FROM attendance a FROM attendance a
JOIN users u ON a.user_id = u.id JOIN users u ON a.user_id = u.id
WHERE a.shift_date BETWEEN ? AND ? WHERE a.shift_date BETWEEN ? AND ?
@@ -112,7 +115,11 @@ function handleGetWorkFund(PDO $pdo): void
$startDate = sprintf('%04d-01-01', $year); $startDate = sprintf('%04d-01-01', $year);
$endDate = sprintf('%04d-%02d-%02d', $year, $maxMonth, cal_days_in_month(CAL_GREGORIAN, $maxMonth, $year)); $endDate = sprintf('%04d-%02d-%02d', $year, $maxMonth, cal_days_in_month(CAL_GREGORIAN, $maxMonth, $year));
$stmt = $pdo->prepare('SELECT * FROM attendance WHERE shift_date BETWEEN ? AND ? ORDER BY shift_date'); $stmt = $pdo->prepare(
'SELECT id, user_id, shift_date, arrival_time, break_start, break_end,
departure_time, notes, project_id, leave_type, leave_hours
FROM attendance WHERE shift_date BETWEEN ? AND ? ORDER BY shift_date'
);
$stmt->execute([$startDate, $endDate]); $stmt->execute([$startDate, $endDate]);
$allRecords = $stmt->fetchAll(); $allRecords = $stmt->fetchAll();
@@ -206,7 +213,13 @@ function handleGetWorkFund(PDO $pdo): void
function handleGetLocation(PDO $pdo, int $recordId): void function handleGetLocation(PDO $pdo, int $recordId): void
{ {
$stmt = $pdo->prepare(" $stmt = $pdo->prepare("
SELECT a.*, CONCAT(u.first_name, ' ', u.last_name) as user_name SELECT a.id, a.user_id, a.shift_date, a.arrival_time,
a.arrival_lat, a.arrival_lng, a.arrival_accuracy, a.arrival_address,
a.break_start, a.break_end, a.departure_time,
a.departure_lat, a.departure_lng, a.departure_accuracy,
a.departure_address, a.notes, a.project_id,
a.leave_type, a.leave_hours, a.created_at,
CONCAT(u.first_name, ' ', u.last_name) as user_name
FROM attendance a FROM attendance a
JOIN users u ON a.user_id = u.id JOIN users u ON a.user_id = u.id
WHERE a.id = ? WHERE a.id = ?
@@ -467,7 +480,11 @@ function handleUpdateBalance(PDO $pdo): void
function handleUpdateAttendance(PDO $pdo, int $recordId): void function handleUpdateAttendance(PDO $pdo, int $recordId): void
{ {
$stmt = $pdo->prepare('SELECT * FROM attendance WHERE id = ?'); $stmt = $pdo->prepare(
'SELECT id, user_id, shift_date, arrival_time, break_start, break_end,
departure_time, notes, project_id, leave_type, leave_hours
FROM attendance WHERE id = ?'
);
$stmt->execute([$recordId]); $stmt->execute([$recordId]);
$record = $stmt->fetch(); $record = $stmt->fetch();
@@ -593,7 +610,10 @@ function handleUpdateAttendance(PDO $pdo, int $recordId): void
function handleDeleteAttendance(PDO $pdo, int $recordId): void function handleDeleteAttendance(PDO $pdo, int $recordId): void
{ {
$stmt = $pdo->prepare('SELECT * FROM attendance WHERE id = ?'); $stmt = $pdo->prepare(
'SELECT id, user_id, shift_date, leave_type, leave_hours
FROM attendance WHERE id = ?'
);
$stmt->execute([$recordId]); $stmt->execute([$recordId]);
$record = $stmt->fetch(); $record = $stmt->fetch();
@@ -920,7 +940,10 @@ function handleGetPrint(PDO $pdo): void
$users = $stmt->fetchAll(); $users = $stmt->fetchAll();
$sql = " $sql = "
SELECT a.*, CONCAT(u.first_name, ' ', u.last_name) as user_name SELECT a.id, a.user_id, a.shift_date, a.arrival_time, a.arrival_address,
a.break_start, a.break_end, a.departure_time, a.departure_address,
a.notes, a.project_id, a.leave_type, a.leave_hours, a.created_at,
CONCAT(u.first_name, ' ', u.last_name) as user_name
FROM attendance a FROM attendance a
JOIN users u ON a.user_id = u.id JOIN users u ON a.user_id = u.id
WHERE a.shift_date BETWEEN ? AND ? WHERE a.shift_date BETWEEN ? AND ?

View File

@@ -217,7 +217,9 @@ function enrichRecordsWithProjectLogs(PDO $pdo, array &$records): void
if (!empty($recordIds)) { if (!empty($recordIds)) {
$placeholders = implode(',', array_fill(0, count($recordIds), '?')); $placeholders = implode(',', array_fill(0, count($recordIds), '?'));
$stmt = $pdo->prepare( $stmt = $pdo->prepare(
"SELECT * FROM attendance_project_logs WHERE attendance_id IN ($placeholders) ORDER BY started_at ASC" "SELECT id, attendance_id, project_id, started_at, ended_at, hours, minutes
FROM attendance_project_logs
WHERE attendance_id IN ($placeholders) ORDER BY started_at ASC"
); );
$stmt->execute($recordIds); $stmt->execute($recordIds);
foreach ($stmt->fetchAll() as $log) { foreach ($stmt->fetchAll() as $log) {

View File

@@ -460,7 +460,9 @@ class AuditLog
// Get logs // Get logs
$sql = " $sql = "
SELECT * SELECT id, user_id, username, user_ip, action,
entity_type, entity_id, description,
old_values, new_values, created_at
FROM audit_logs FROM audit_logs
$whereClause $whereClause
ORDER BY created_at DESC ORDER BY created_at DESC
@@ -503,7 +505,9 @@ class AuditLog
$pdo = db(); $pdo = db();
$stmt = $pdo->prepare(' $stmt = $pdo->prepare('
SELECT * SELECT id, user_id, username, user_ip, action,
entity_type, entity_id, description,
old_values, new_values, created_at
FROM audit_logs FROM audit_logs
WHERE user_id = ? WHERE user_id = ?
ORDER BY created_at DESC ORDER BY created_at DESC
@@ -531,7 +535,9 @@ class AuditLog
$pdo = db(); $pdo = db();
$stmt = $pdo->prepare(' $stmt = $pdo->prepare('
SELECT * SELECT id, user_id, username, user_ip, action,
entity_type, entity_id, description,
old_values, new_values, created_at
FROM audit_logs FROM audit_logs
WHERE entity_type = ? AND entity_id = ? WHERE entity_type = ? AND entity_id = ?
ORDER BY created_at DESC ORDER BY created_at DESC

View File

@@ -245,8 +245,11 @@ class JWTAuth
// First check if token exists (regardless of expiry) // First check if token exists (regardless of expiry)
$stmt = $pdo->prepare(' $stmt = $pdo->prepare('
SELECT rt.*, u.id as user_id, u.username, u.email, u.first_name, u.last_name, SELECT rt.id, rt.user_id, rt.token_hash, rt.expires_at,
u.is_active, r.name as role_name, r.display_name as role_display_name rt.replaced_at, rt.remember_me,
u.id as user_id, u.username, u.email,
u.first_name, u.last_name, u.is_active,
r.name as role_name, r.display_name as role_display_name
FROM refresh_tokens rt FROM refresh_tokens rt
JOIN users u ON rt.user_id = u.id JOIN users u ON rt.user_id = u.id
LEFT JOIN roles r ON u.role_id = r.id LEFT JOIN roles r ON u.role_id = r.id

View File

@@ -0,0 +1,84 @@
<?php
/**
* Pagination helper - extrakce spolecne logiky pro strankovani seznamu.
*/
declare(strict_types=1);
class PaginationHelper
{
private const DEFAULT_PER_PAGE = 25;
private const MAX_PER_PAGE = 500;
/**
* Nacte pagination parametry z GET requestu.
*
* @param array<string, string> $sortMap
* @return array{page: int, per_page: int, sort: string, order: string, search: string}
*/
public static function parseParams(array $sortMap, string $defaultSort = 'created_at'): array
{
$sort = $_GET['sort'] ?? $defaultSort;
$order = strtoupper($_GET['order'] ?? 'DESC') === 'ASC' ? 'ASC' : 'DESC';
$page = max(1, (int) ($_GET['page'] ?? 1));
$perPage = min(self::MAX_PER_PAGE, max(1, (int) ($_GET['per_page'] ?? self::DEFAULT_PER_PAGE)));
$search = trim($_GET['search'] ?? '');
if (!isset($sortMap[$sort])) {
errorResponse('Neplatný parametr řazení', 400);
}
return [
'page' => $page,
'per_page' => $perPage,
'sort' => $sortMap[$sort],
'order' => $order,
'search' => $search ? mb_substr($search, 0, 100) : '',
];
}
/**
* Spusti COUNT + SELECT dotazy s pagination a vrati vysledek.
*
* @param PDO $pdo
* @param string $countSql - COUNT(*) dotaz
* @param string $dataSql - SELECT dotaz (bez LIMIT/OFFSET)
* @param array<int, mixed> $params - parametry pro prepared statement
* @param array{page: int, per_page: int, sort: string, order: string} $pagination
* @return array{items: array<int, array<string, mixed>>,
* pagination: array{total: int, page: int, per_page: int, total_pages: int}}
*/
public static function paginate(
PDO $pdo,
string $countSql,
string $dataSql,
array $params,
array $pagination
): array {
$page = $pagination['page'];
$perPage = $pagination['per_page'];
$stmt = $pdo->prepare($countSql);
$stmt->execute($params);
$total = (int) $stmt->fetchColumn();
$offset = ($page - 1) * $perPage;
$totalPages = (int) ceil($total / $perPage);
$sql = "{$dataSql} LIMIT {$perPage} OFFSET {$offset}";
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$items = $stmt->fetchAll();
return [
'items' => $items,
'pagination' => [
'total' => $total,
'page' => $page,
'per_page' => $perPage,
'total_pages' => $totalPages,
],
];
}
}

139
api/includes/Validator.php Normal file
View File

@@ -0,0 +1,139 @@
<?php
/**
* Validacni helper pro API vstupy.
*
* Pouziti:
* $v = new Validator($input);
* $v->required('name')->string('name', 1, 255);
* $v->required('email')->email('email');
* $v->int('amount', 0, 1000000);
* $v->in('status', ['active', 'inactive']);
* if ($v->fails()) errorResponse($v->firstError());
*/
declare(strict_types=1);
class Validator
{
/** @var array<string, mixed> */
private array $data;
/** @var array<string, string> */
private array $errors = [];
/** @param array<string, mixed> $data */
public function __construct(array $data)
{
$this->data = $data;
}
public function required(string $field, string $label = ''): self
{
$value = $this->data[$field] ?? null;
if ($value === null || $value === '') {
$this->errors[$field] = ($label ?: $field) . ' je povinné pole';
}
return $this;
}
public function string(string $field, int $min = 0, int $max = 0, string $label = ''): self
{
$value = $this->data[$field] ?? null;
if ($value === null || $value === '') {
return $this;
}
if (!is_string($value)) {
$this->errors[$field] = ($label ?: $field) . ' musí být text';
return $this;
}
$len = mb_strlen($value);
if ($min > 0 && $len < $min) {
$this->errors[$field] = ($label ?: $field) . " musí mít alespoň {$min} znaků";
} elseif ($max > 0 && $len > $max) {
$this->errors[$field] = ($label ?: $field) . " nesmí překročit {$max} znaků";
}
return $this;
}
public function int(string $field, ?int $min = null, ?int $max = null, string $label = ''): self
{
$value = $this->data[$field] ?? null;
if ($value === null || $value === '') {
return $this;
}
if (!is_numeric($value)) {
$this->errors[$field] = ($label ?: $field) . ' musí být číslo';
return $this;
}
$intVal = (int) $value;
if ($min !== null && $intVal < $min) {
$this->errors[$field] = ($label ?: $field) . " musí být alespoň {$min}";
} elseif ($max !== null && $intVal > $max) {
$this->errors[$field] = ($label ?: $field) . " nesmí překročit {$max}";
}
return $this;
}
public function email(string $field, string $label = ''): self
{
$value = $this->data[$field] ?? null;
if ($value === null || $value === '') {
return $this;
}
if (!is_string($value) || !filter_var($value, FILTER_VALIDATE_EMAIL)) {
$this->errors[$field] = ($label ?: $field) . ' musí být platný e-mail';
}
return $this;
}
/**
* @param list<string> $allowed
*/
public function in(string $field, array $allowed, string $label = ''): self
{
$value = $this->data[$field] ?? null;
if ($value === null || $value === '') {
return $this;
}
if (!in_array($value, $allowed, true)) {
$this->errors[$field] = ($label ?: $field) . ' má neplatnou hodnotu';
}
return $this;
}
public function numeric(string $field, ?float $min = null, ?float $max = null, string $label = ''): self
{
$value = $this->data[$field] ?? null;
if ($value === null || $value === '') {
return $this;
}
if (!is_numeric($value)) {
$this->errors[$field] = ($label ?: $field) . ' musí být číslo';
return $this;
}
$numVal = (float) $value;
if ($min !== null && $numVal < $min) {
$this->errors[$field] = ($label ?: $field) . " musí být alespoň {$min}";
} elseif ($max !== null && $numVal > $max) {
$this->errors[$field] = ($label ?: $field) . " nesmí překročit {$max}";
}
return $this;
}
public function fails(): bool
{
return count($this->errors) > 0;
}
public function firstError(): string
{
return reset($this->errors) ?: '';
}
/** @return array<string, string> */
public function errors(): array
{
return $this->errors;
}
}

View File

@@ -5,7 +5,6 @@
"require": { "require": {
"php": ">=8.1", "php": ">=8.1",
"firebase/php-jwt": "^6.11", "firebase/php-jwt": "^6.11",
"tecnickcom/tcpdf": "^6.7",
"robthree/twofactorauth": "^3.0", "robthree/twofactorauth": "^3.0",
"chillerlan/php-qrcode": "^5.0" "chillerlan/php-qrcode": "^5.0"
}, },

View File

@@ -0,0 +1,20 @@
-- Indexy pro casto filtrovane sloupce
-- Spustit rucne: mysql -u root app < migrations/001_add_indexes.sql
-- Dochazka - filtrovani dle uzivatele a data smeny
CREATE INDEX idx_attendance_user_date ON attendance(user_id, shift_date);
-- Vydane faktury - filtrovani dle statusu a data vystaveni (KPI karty, seznamy)
CREATE INDEX idx_invoices_status_issue ON invoices(status, issue_date);
-- Vydane faktury - detekce po splatnosti
CREATE INDEX idx_invoices_due_date ON invoices(due_date);
-- Nabidky - razeni a vyhledavani dle cisla
CREATE INDEX idx_quotations_number ON quotations(quotation_number);
-- Refresh tokeny - cleanup a validace dle uzivatele a expirace
CREATE INDEX idx_refresh_tokens_user_exp ON refresh_tokens(user_id, expires_at);
-- Audit log - razeni a mazani starych zaznamu
CREATE INDEX idx_audit_log_created ON audit_log(created_at);

View File

@@ -0,0 +1 @@
INSERT INTO permissions (name, description) VALUES ('settings.audit', 'Zobrazení audit logu') ON DUPLICATE KEY UPDATE description = VALUES(description);

View File

@@ -1,5 +1,5 @@
import { lazy, Suspense } from 'react' import { lazy, Suspense } from 'react'
import { Routes, Route, Navigate } from 'react-router-dom' import { Routes, Route } from 'react-router-dom'
import { AuthProvider } from './context/AuthContext' import { AuthProvider } from './context/AuthContext'
import { AlertProvider } from './context/AlertContext' import { AlertProvider } from './context/AlertContext'
import ErrorBoundary from './components/ErrorBoundary' import ErrorBoundary from './components/ErrorBoundary'
@@ -45,6 +45,8 @@ const Invoices = lazy(() => import('./pages/Invoices'))
const InvoiceCreate = lazy(() => import('./pages/InvoiceCreate')) const InvoiceCreate = lazy(() => import('./pages/InvoiceCreate'))
const InvoiceDetail = lazy(() => import('./pages/InvoiceDetail')) const InvoiceDetail = lazy(() => import('./pages/InvoiceDetail'))
const Settings = lazy(() => import('./pages/Settings')) const Settings = lazy(() => import('./pages/Settings'))
const AuditLog = lazy(() => import('./pages/AuditLog'))
const NotFound = lazy(() => import('./pages/NotFound'))
export default function AdminApp() { export default function AdminApp() {
return ( return (
@@ -85,8 +87,9 @@ export default function AdminApp() {
<Route path="invoices/new" element={<InvoiceCreate />} /> <Route path="invoices/new" element={<InvoiceCreate />} />
<Route path="invoices/:id" element={<InvoiceDetail />} /> <Route path="invoices/:id" element={<InvoiceDetail />} />
<Route path="settings" element={<Settings />} /> <Route path="settings" element={<Settings />} />
<Route path="audit-log" element={<AuditLog />} />
</Route> </Route>
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<NotFound />} />
</Routes> </Routes>
</Suspense> </Suspense>
</ErrorBoundary> </ErrorBoundary>

View File

@@ -2332,6 +2332,112 @@ img {
cursor: grabbing; cursor: grabbing;
} }
/* ============================================================================
Pagination
============================================================================ */
.admin-pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 0.75rem 0;
font-size: 13px;
}
.admin-pagination-info {
color: var(--text-muted);
font-family: var(--font-mono);
font-size: 12px;
white-space: nowrap;
}
.admin-pagination-controls {
display: flex;
align-items: center;
gap: 2px;
}
.admin-pagination-page {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 32px;
height: 32px;
padding: 0 6px;
border: 1px solid transparent;
border-radius: var(--border-radius-sm);
background: none;
color: var(--text-secondary);
font-size: 13px;
font-family: var(--font-mono);
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.admin-pagination-page:hover {
background: var(--bg-secondary);
color: var(--text-primary);
}
.admin-pagination-page.active {
background: var(--accent-color);
color: #fff;
border-color: var(--accent-color);
font-weight: 600;
}
.admin-pagination-ellipsis {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
color: var(--text-muted);
font-size: 14px;
}
.admin-pagination-select {
padding: 4px 8px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
background: var(--bg-primary);
color: var(--text-secondary);
font-size: 12px;
cursor: pointer;
}
@media (max-width: 640px) {
.admin-pagination {
flex-wrap: wrap;
gap: 0.5rem;
}
.admin-pagination-info {
order: 2;
width: 100%;
text-align: center;
}
}
/* Error stack (DEV only) */
.admin-error-stack {
max-width: 600px;
max-height: 200px;
overflow: auto;
padding: 0.75rem 1rem;
margin: 0;
border-radius: var(--border-radius-sm);
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--danger-color);
font-family: var(--font-mono);
font-size: 11px;
line-height: 1.5;
text-align: left;
white-space: pre-wrap;
word-break: break-word;
}
/* Keyboard shortcut badge */ /* Keyboard shortcut badge */
.admin-kbd { .admin-kbd {
display: inline-block; display: inline-block;

View File

@@ -0,0 +1,146 @@
import { Link } from 'react-router-dom'
import {
formatDate, formatDatetime, formatTime,
calculateWorkMinutes, formatMinutes,
getLeaveTypeName, getLeaveTypeBadgeClass
} from '../utils/attendanceHelpers'
function formatBreak(record) {
if (record.break_start && record.break_end) {
return `${formatTime(record.break_start)} - ${formatTime(record.break_end)}`
}
if (record.break_start) {
return `${formatTime(record.break_start)} - ?`
}
return '—'
}
function renderProjectCell(record) {
if (record.project_logs && record.project_logs.length > 0) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.125rem' }}>
{record.project_logs.map((log, i) => {
let h, m, isActive = false
if (log.hours !== null && log.hours !== undefined) {
h = parseInt(log.hours) || 0
m = parseInt(log.minutes) || 0
} else {
isActive = !log.ended_at
const end = log.ended_at ? new Date(log.ended_at) : new Date()
const mins = Math.floor((end - new Date(log.started_at)) / 60000)
h = Math.floor(mins / 60)
m = mins % 60
}
return (
<span key={log.id || i} className="admin-badge" style={{ fontSize: '0.7rem', display: 'inline-block', background: isActive ? 'var(--accent-light)' : undefined }}>
{log.project_name || `#${log.project_id}`} ({h}:{String(m).padStart(2, '0')}h{isActive ? ' ▸' : ''})
</span>
)
})}
</div>
)
}
if (record.project_name) {
return <span className="admin-badge admin-badge-wrap" style={{ fontSize: '0.75rem' }}>{record.project_name}</span>
}
return '—'
}
export default function AttendanceShiftTable({ records, onEdit, onDelete }) {
if (records.length === 0) {
return (
<div className="admin-empty-state">
<p>Za tento měsíc nejsou žádné záznamy.</p>
</div>
)
}
return (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Datum</th>
<th>Zaměstnanec</th>
<th>Typ</th>
<th>Příchod</th>
<th>Pauza</th>
<th>Odchod</th>
<th>Hodiny</th>
<th>Projekt</th>
<th>GPS</th>
<th>Poznámka</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{records.map((record) => {
const leaveType = record.leave_type || 'work'
const isLeave = leaveType !== 'work'
const workMinutes = isLeave
? (record.leave_hours || 8) * 60
: calculateWorkMinutes(record)
const hasLocation = (record.arrival_lat && record.arrival_lng) || (record.departure_lat && record.departure_lng)
return (
<tr key={record.id}>
<td className="admin-mono">{formatDate(record.shift_date)}</td>
<td>{record.user_name}</td>
<td>
<span className={`attendance-leave-badge ${getLeaveTypeBadgeClass(leaveType)}`}>
{getLeaveTypeName(leaveType)}
</span>
</td>
<td className="admin-mono">{isLeave ? '—' : formatDatetime(record.arrival_time)}</td>
<td className="admin-mono">
{isLeave ? '—' : formatBreak(record)}
</td>
<td className="admin-mono">{isLeave ? '—' : formatDatetime(record.departure_time)}</td>
<td className="admin-mono">{workMinutes > 0 ? `${formatMinutes(workMinutes)} h` : '—'}</td>
<td>
{renderProjectCell(record)}
</td>
<td>
{hasLocation ? (
<Link to={`/attendance/location/${record.id}`} className="attendance-gps-link" title="Zobrazit polohu" aria-label="Zobrazit polohu">
📍
</Link>
) : '—'}
</td>
<td style={{ maxWidth: '100px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={record.notes || ''}>
{record.notes || ''}
</td>
<td>
<div className="admin-table-actions">
<button
onClick={() => onEdit(record)}
className="admin-btn-icon"
title="Upravit"
aria-label="Upravit"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
<button
onClick={() => onDelete(record)}
className="admin-btn-icon danger"
title="Smazat"
aria-label="Smazat"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)
}

View File

@@ -1,6 +1,7 @@
import { useEffect } from 'react' import { useEffect } from 'react'
import { motion, AnimatePresence } from 'framer-motion' import { motion, AnimatePresence } from 'framer-motion'
import useModalLock from '../hooks/useModalLock' import useModalLock from '../hooks/useModalLock'
import useFocusTrap from '../hooks/useFocusTrap'
export default function ConfirmModal({ export default function ConfirmModal({
isOpen, isOpen,
@@ -14,6 +15,7 @@ export default function ConfirmModal({
loading = false loading = false
}) { }) {
useModalLock(isOpen) useModalLock(isOpen)
const trapRef = useFocusTrap(isOpen)
useEffect(() => { useEffect(() => {
if (!isOpen) return if (!isOpen) return
@@ -70,6 +72,7 @@ export default function ConfirmModal({
> >
<div className="admin-modal-backdrop" onClick={onClose} /> <div className="admin-modal-backdrop" onClick={onClose} />
<motion.div <motion.div
ref={trapRef}
className="admin-modal admin-confirm-modal" className="admin-modal admin-confirm-modal"
role="alertdialog" role="alertdialog"
aria-modal="true" aria-modal="true"

View File

@@ -1,10 +1,10 @@
import { Component } from 'react' import { Component } from 'react'
export default class ErrorBoundary extends Component { export default class ErrorBoundary extends Component {
state = { hasError: false } state = { hasError: false, error: null }
static getDerivedStateFromError() { static getDerivedStateFromError(error) {
return { hasError: true } return { hasError: true, error }
} }
componentDidCatch(error, info) { componentDidCatch(error, info) {
@@ -16,29 +16,32 @@ export default class ErrorBoundary extends Component {
render() { render() {
if (this.state.hasError) { if (this.state.hasError) {
return ( return (
<div style={{ <div className="admin-empty-state" style={{ minHeight: '60vh', justifyContent: 'center' }}>
minHeight: '50vh', <div className="admin-empty-icon" style={{ width: 80, height: 80, marginBottom: '1.5rem' }}>
display: 'flex', <svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
flexDirection: 'column', <path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
alignItems: 'center', <line x1="12" y1="9" x2="12" y2="13" />
justifyContent: 'center', <line x1="12" y1="17" x2="12.01" y2="17" />
gap: '1rem', </svg>
color: 'var(--text-secondary, #888)' </div>
}}> <p style={{ marginBottom: '0.5rem' }}>Něco se pokazilo při načítání stránky.</p>
<p>Něco se pokazilo při načítání stránky.</p> {import.meta.env.DEV && this.state.error && (
<button <pre className="admin-error-stack">
onClick={() => window.location.reload()} {this.state.error.message}
style={{ {this.state.error.stack && `\n${this.state.error.stack}`}
padding: '0.5rem 1.5rem', </pre>
borderRadius: '8px', )}
border: '1px solid var(--border-color, #333)', <div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.5rem' }}>
background: 'var(--bg-secondary, #1a1a1a)', <a href="/" className="admin-btn admin-btn-secondary">
color: 'var(--text-primary, #fff)', Zpět na Dashboard
cursor: 'pointer' </a>
}} <button
> onClick={() => window.location.reload()}
Načíst znovu className="admin-btn admin-btn-primary"
</button> >
Načíst znovu
</button>
</div>
</div> </div>
) )
} }

View File

@@ -0,0 +1,23 @@
/**
* Formularovy wrapper pro label + input + error.
* Nahrazuje opakovany pattern admin-form-group + has-error + admin-form-error.
*
* @param {string} label - Text labelu
* @param {string} [error] - Chybova zprava (zobrazi se pod inputem)
* @param {boolean} [required] - Zobrazi cervenu hvezdicku
* @param {string} [className] - Extra CSS trida na wrapperu
* @param {React.ReactNode} children - Input/select/textarea element
*/
export default function FormField({ label, error, required, className, children }) {
const groupClass = `admin-form-group${error ? ' has-error' : ''}${className ? ` ${className}` : ''}`
return (
<div className={groupClass}>
<label className={`admin-form-label${required ? ' required' : ''}`}>
{label}
</label>
{children}
{error && <span className="admin-form-error">{error}</span>}
</div>
)
}

View File

@@ -0,0 +1,90 @@
import { useState, useEffect, useMemo } from 'react'
export default function OfferCustomerPicker({
customers,
customerId,
customerName,
onSelect,
onClear,
error,
readOnly
}) {
const [customerSearch, setCustomerSearch] = useState('')
const [showDropdown, setShowDropdown] = useState(false)
// Close dropdown on outside click
useEffect(() => {
const handleClickOutside = () => setShowDropdown(false)
if (showDropdown) {
document.addEventListener('click', handleClickOutside)
return () => document.removeEventListener('click', handleClickOutside)
}
}, [showDropdown])
const filteredCustomers = useMemo(() => {
if (!customerSearch) return customers
const q = customerSearch.toLowerCase()
return customers.filter(c =>
(c.name || '').toLowerCase().includes(q) ||
(c.company_id || '').includes(customerSearch) ||
(c.city || '').toLowerCase().includes(q)
)
}, [customers, customerSearch])
const handleSelect = (customer) => {
onSelect(customer)
setCustomerSearch('')
setShowDropdown(false)
}
return (
<div className={`admin-form-group${error ? ' has-error' : ''}`}>
<label className="admin-form-label required">Zákazník</label>
{customerId && (
<div className="offers-customer-selected">
<span>{customerName}</span>
{!readOnly && (
<button type="button" onClick={onClear} className="admin-btn-icon" title="Odebrat zákazníka" aria-label="Odebrat zákazníka">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
)}
</div>
)}
{!customerId && !readOnly && (
<div className="offers-customer-select" onClick={(e) => e.stopPropagation()}>
<input
type="text"
value={customerSearch}
onChange={(e) => { setCustomerSearch(e.target.value); setShowDropdown(true) }}
onFocus={() => setShowDropdown(true)}
className="admin-form-input"
placeholder="Hledat zákazníka..."
/>
{showDropdown && (
<div className="offers-customer-dropdown">
{filteredCustomers.length === 0 ? (
<div className="offers-customer-dropdown-empty">
Žádní zákazníci
</div>
) : (
filteredCustomers.slice(0, 10).map(c => (
<div
key={c.id}
className="offers-customer-dropdown-item"
onMouseDown={() => handleSelect(c)}
>
<div>{c.name}</div>
{c.city && <div>{c.city}</div>}
</div>
))
)}
</div>
)}
</div>
)}
{error && <span className="admin-form-error">{error}</span>}
</div>
)
}

View File

@@ -0,0 +1,106 @@
import { useMemo } from 'react'
/**
* Strankovaci komponenta pro seznamove stranky.
*
* @param {object} pagination - {total, page, per_page, total_pages}
* @param {function} onPageChange - callback(newPage)
* @param {function} [onPerPageChange] - callback(newPerPage)
*/
export default function Pagination({ pagination, onPageChange, onPerPageChange }) {
const page = pagination?.page ?? 1
const totalPages = pagination?.total_pages ?? 1
const total = pagination?.total ?? 0
const perPage = pagination?.per_page ?? 25
const visiblePages = useMemo(() => {
const pages = []
const maxVisible = 5
let start = Math.max(1, page - Math.floor(maxVisible / 2))
const end = Math.min(totalPages, start + maxVisible - 1)
if (end - start < maxVisible - 1) {
start = Math.max(1, end - maxVisible + 1)
}
if (start > 1) {
pages.push(1)
if (start > 2) { pages.push('...') }
}
for (let i = start; i <= end; i++) {
pages.push(i)
}
if (end < totalPages) {
if (end < totalPages - 1) { pages.push('...') }
pages.push(totalPages)
}
return pages
}, [page, totalPages])
if (!pagination || totalPages <= 1) { return null }
const from = (page - 1) * perPage + 1
const to = Math.min(page * perPage, total)
return (
<div className="admin-pagination">
<span className="admin-pagination-info">
{from}{to} z {total}
</span>
<div className="admin-pagination-controls">
<button
className="admin-btn-secondary admin-btn-sm"
disabled={page <= 1}
onClick={() => onPageChange(page - 1)}
aria-label="Předchozí stránka"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
{visiblePages.map((p, i) => (
p === '...'
? <span key={`ellipsis-${i}`} className="admin-pagination-ellipsis"></span>
: (
<button
key={p}
className={`admin-pagination-page${p === page ? ' active' : ''}`}
onClick={() => onPageChange(p)}
>
{p}
</button>
)
))}
<button
className="admin-btn-secondary admin-btn-sm"
disabled={page >= totalPages}
onClick={() => onPageChange(page + 1)}
aria-label="Další stránka"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
</div>
{onPerPageChange && (
<select
className="admin-pagination-select"
value={perPage}
onChange={(e) => onPerPageChange(Number(e.target.value))}
aria-label="Záznamů na stránku"
>
{[10, 25, 50, 100].map((n) => (
<option key={n} value={n}>{n} / strana</option>
))}
</select>
)}
</div>
)
}

View File

@@ -264,6 +264,20 @@ const menuSections = [
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" /> <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg> </svg>
) )
},
{
path: '/audit-log',
label: 'Audit log',
permission: 'settings.audit',
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" />
</svg>
)
} }
] ]
} }

View File

@@ -0,0 +1,65 @@
import { ENTITY_TYPE_LABELS, getActivityIconClass, formatActivityTime } from '../../utils/dashboardHelpers'
function getActivityIcon(action) {
switch (action) {
case 'create':
return (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
</svg>
)
case 'update':
return (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
)
case 'delete':
return (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="3 6 5 6 21 6" /><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
)
case 'login':
return (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4" /><polyline points="10 17 15 12 10 7" /><line x1="15" y1="12" x2="3" y2="12" />
</svg>
)
default:
return (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10" /><line x1="12" y1="16" x2="12" y2="12" /><line x1="12" y1="8" x2="12.01" y2="8" />
</svg>
)
}
}
export default function DashActivityFeed({ activities }) {
if (!activities) {
return null
}
return (
<div className="admin-card dash-activity-card">
<div className="admin-card-header">
<h2 className="admin-card-title">Poslední aktivita</h2>
</div>
<div className="admin-card-body" style={{ padding: 0 }}>
{activities.map((act) => (
<div key={act.id} className="dash-activity-row">
<div className={`dash-activity-icon ${getActivityIconClass(act.action)}`}>
{getActivityIcon(act.action)}
</div>
<div className="dash-activity-main">
<div className="dash-activity-text">{act.description}</div>
<div className="dash-activity-sub">{act.username || 'Systém'} · {ENTITY_TYPE_LABELS[act.entity_type] || act.entity_type}</div>
</div>
<div className="dash-activity-time admin-mono">{formatActivityTime(act.created_at)}</div>
</div>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,33 @@
import { Link } from 'react-router-dom'
import { LEAVE_TYPE_LABELS, STATUS_DOT_CLASS, STATUS_LABELS } from '../../utils/dashboardHelpers'
export default function DashAttendanceToday({ attendance }) {
if (!attendance) {
return null
}
return (
<div className="admin-card dash-attendance-card">
<div className="admin-card-header" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<h2 className="admin-card-title">Docházka dnes</h2>
<Link to="/attendance/admin" className="admin-btn admin-btn-primary admin-btn-sm">Detail &rarr;</Link>
</div>
<div className="admin-card-body" style={{ padding: 0 }}>
{attendance.users.map((u) => (
<div key={u.user_id} className="dash-presence-row">
<div className={`dash-presence-avatar ${STATUS_DOT_CLASS[u.status]}`}>
{u.initials || '?'}
</div>
<div className="dash-presence-name">{u.name}</div>
<div className="dash-presence-end">
<span className={`dash-presence-label ${STATUS_DOT_CLASS[u.status]}`}>
{u.status === 'leave' ? (LEAVE_TYPE_LABELS[u.leave_type] || 'Nepřítomen') : STATUS_LABELS[u.status]}
</span>
{u.arrived_at && <span className="admin-mono dash-presence-time">{u.arrived_at}</span>}
</div>
</div>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,91 @@
import { motion } from 'framer-motion'
import { formatCurrency } from '../../utils/formatters'
function buildKpiCards(dashData) {
const cards = []
if (dashData?.attendance) {
cards.push({
label: 'Přítomní dnes',
value: `${dashData.attendance.present_today}`,
sub: `/ ${dashData.attendance.total_active}`,
color: 'success',
footer: dashData.attendance.on_leave > 0 ? `${dashData.attendance.on_leave} nepřítomných` : null,
})
}
if (dashData?.offers) {
cards.push({
label: 'Otevřené nabídky',
value: `${dashData.offers.open_count}`,
color: 'info',
footer: dashData.offers.created_this_month > 0 ? `${dashData.offers.created_this_month} tento měsíc` : null,
})
}
if (dashData?.invoices) {
cards.push(buildInvoiceKpi(dashData.invoices))
}
if (dashData?.leave_pending) {
cards.push({
label: 'Žádosti o volno',
value: `${dashData.leave_pending.count}`,
color: 'danger',
footer: dashData.leave_pending.count > 0 ? 'čeká na schválení' : null,
})
}
return cards
}
function buildInvoiceKpi(invoices) {
const rev = invoices.revenue_this_month || []
const hasForeign = rev.some(r => r.currency !== 'CZK')
const hasCzkTotal = hasForeign && invoices.revenue_czk !== null && invoices.revenue_czk !== undefined
const fallbackText = rev.length > 0
? rev.map(r => formatCurrency(r.amount, r.currency)).join(' · ')
: '0 Kč'
const revenueText = hasCzkTotal
? formatCurrency(invoices.revenue_czk, 'CZK')
: fallbackText
const detailText = hasForeign && rev.length > 0
? rev.map(r => formatCurrency(r.amount, r.currency)).join(' · ')
: null
const unpaidText = invoices.unpaid_count > 0
? `${invoices.unpaid_count} neuhrazených`
: null
const footerParts = [detailText, unpaidText].filter(Boolean)
return {
label: 'Tržby (měsíc)',
value: revenueText,
color: 'warning',
footer: footerParts.length > 0 ? footerParts.join(' · ') : null,
}
}
const KPI_CLASS_MAP = { 4: 'dash-kpi-4', 3: 'dash-kpi-3', 2: 'dash-kpi-2', 1: 'dash-kpi-1' }
export default function DashKpiCards({ dashData }) {
const kpiCards = buildKpiCards(dashData)
if (kpiCards.length === 0) {
return null
}
const kpiClass = KPI_CLASS_MAP[Math.min(kpiCards.length, 4)] || 'dash-kpi-4'
return (
<motion.div
className={`dash-kpi-grid ${kpiClass}`}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.1 }}
>
{kpiCards.map((kpi) => (
<div key={kpi.label} className={`admin-stat-card ${kpi.color}`}>
<div className="admin-stat-label">{kpi.label}</div>
<div className="admin-stat-value admin-mono">
{kpi.value}
{kpi.sub && <small className="text-muted" style={{ fontSize: '0.75em', fontWeight: 500, marginLeft: '0.25rem' }}>{kpi.sub}</small>}
</div>
{kpi.footer && <div className="admin-stat-footer">{kpi.footer}</div>}
</div>
))}
</motion.div>
)
}

View File

@@ -0,0 +1,310 @@
import { useState, useRef } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { useAuth } from '../../context/AuthContext'
import { useAlert } from '../../context/AlertContext'
import useModalLock from '../../hooks/useModalLock'
import apiFetch from '../../utils/api'
const API_BASE = '/api/admin'
export default function DashProfile({
totpEnabled, totpLoading, totpSubmitting,
onStart2FASetup, onConfirm2FA, onDisable2FA,
totpSecret, totpQrUri, totpCode, setTotpCode,
backupCodes, setBackupCodes,
show2FASetup, setShow2FASetup,
show2FADisable, setShow2FADisable,
disableCode, setDisableCode,
}) {
const { user, updateUser } = useAuth()
const alert = useAlert()
const totpSetupRef = useRef(null)
const [showModal, setShowModal] = useState(false)
const [formData, setFormData] = useState({
username: '', email: '', password: '', first_name: '', last_name: ''
})
useModalLock(showModal)
const openEditModal = () => {
const nameParts = (user?.fullName || '').split(' ')
setFormData({
username: user?.username || '',
email: user?.email || '',
password: '',
first_name: nameParts[0] || '',
last_name: nameParts.slice(1).join(' ') || ''
})
setShowModal(true)
}
const handleSubmit = async (e) => {
e?.preventDefault()
const dataToSave = { ...formData }
try {
const response = await apiFetch(`${API_BASE}/profile.php`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(dataToSave)
})
const data = await response.json()
if (data.success) {
updateUser({
username: dataToSave.username,
email: dataToSave.email,
fullName: `${dataToSave.first_name} ${dataToSave.last_name}`.trim()
})
setShowModal(false)
await new Promise(resolve => setTimeout(resolve, 300))
alert.success('Profil byl upraven')
} else {
alert.error(data.error || 'Nepodařilo se uložit profil')
}
} catch {
alert.error('Chyba připojení')
}
}
function getTotpStatusText() {
if (totpLoading) {
return 'Načítání...'
}
return totpEnabled ? 'Aktivní' : 'Neaktivní'
}
return (
<>
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.25 }}
>
<div className="admin-card-header" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<h2 className="admin-card-title">Váš účet</h2>
<button onClick={openEditModal} className="admin-btn admin-btn-secondary admin-btn-sm">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
Upravit
</button>
</div>
<div className="admin-card-body">
<div className="dash-profile-grid">
<div className="dash-profile-item">
<span className="dash-profile-label">Uživatel</span>
<span className="dash-profile-value">{user?.username}</span>
</div>
<div className="dash-profile-item">
<span className="dash-profile-label">E-mail</span>
<span className="dash-profile-value">{user?.email}</span>
</div>
<div className="dash-profile-item">
<span className="dash-profile-label">Jméno</span>
<span className="dash-profile-value">{user?.fullName}</span>
</div>
<div className="dash-profile-item">
<span className="dash-profile-label">Role</span>
<span className="dash-profile-value">{user?.roleDisplay || user?.role}</span>
</div>
</div>
{/* 2FA Section */}
<div style={{ borderTop: '1px solid var(--border-color)', marginTop: '1rem', paddingTop: '1rem' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<div style={{
width: 36, height: 36, borderRadius: '50%',
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: totpEnabled ? 'var(--success-light)' : 'rgba(var(--text-secondary-rgb, 107, 114, 128), 0.1)',
color: totpEnabled ? 'var(--success)' : 'var(--text-secondary)'
}}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" /><path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
</div>
<div>
<div style={{ fontWeight: 500, fontSize: '0.875rem' }}>Dvoufaktorové ověření (2FA)</div>
<div className={totpEnabled ? 'text-success' : 'text-secondary'} style={{ fontSize: '0.75rem' }}>
{getTotpStatusText()}
</div>
</div>
</div>
{!totpLoading && (
totpEnabled ? (
<button onClick={() => { setDisableCode(''); setShow2FADisable(true) }} className="admin-btn admin-btn-primary admin-btn-sm">
Deaktivovat
</button>
) : (
<button onClick={onStart2FASetup} disabled={totpSubmitting} className="admin-btn admin-btn-primary admin-btn-sm">
{totpSubmitting ? 'Generuji...' : 'Aktivovat'}
</button>
)
)}
</div>
</div>
</div>
</motion.div>
{/* Edit Profile Modal */}
<AnimatePresence>
{showModal && (
<motion.div className="admin-modal-overlay" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.2 }}>
<div className="admin-modal-backdrop" onClick={() => setShowModal(false)} />
<motion.div className="admin-modal" initial={{ opacity: 0, scale: 0.95, y: 20 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.95, y: 20 }} transition={{ duration: 0.2 }}>
<div className="admin-modal-header"><h2 className="admin-modal-title">Upravit profil</h2></div>
<div className="admin-modal-body">
<div className="admin-form">
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">Jméno</label>
<input type="text" value={formData.first_name} onChange={(e) => setFormData({ ...formData, first_name: e.target.value })} required className="admin-form-input" />
</div>
<div className="admin-form-group">
<label className="admin-form-label">Příjmení</label>
<input type="text" value={formData.last_name} onChange={(e) => setFormData({ ...formData, last_name: e.target.value })} required className="admin-form-input" />
</div>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Uživatelské jméno</label>
<input type="text" value={formData.username} onChange={(e) => setFormData({ ...formData, username: e.target.value })} required className="admin-form-input" />
</div>
<div className="admin-form-group">
<label className="admin-form-label">E-mail</label>
<input type="email" value={formData.email} onChange={(e) => setFormData({ ...formData, email: e.target.value })} required className="admin-form-input" />
</div>
<div className="admin-form-group">
<label className="admin-form-label">Nové heslo (ponechte prázdné pro zachování stávajícího)</label>
<input type="password" value={formData.password} onChange={(e) => setFormData({ ...formData, password: e.target.value })} className="admin-form-input" />
</div>
</div>
</div>
<div className="admin-modal-footer">
<button type="button" onClick={() => setShowModal(false)} className="admin-btn admin-btn-secondary">Zrušit</button>
<button type="button" onClick={handleSubmit} className="admin-btn admin-btn-primary">Uložit změny</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
{/* 2FA Setup Modal */}
<AnimatePresence>
{show2FASetup && (
<motion.div className="admin-modal-overlay" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.2 }}>
<div className="admin-modal-backdrop" onClick={() => { if (!backupCodes) { setShow2FASetup(false) } }} />
<motion.div className="admin-modal" initial={{ opacity: 0, scale: 0.95, y: 20 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.95, y: 20 }} transition={{ duration: 0.2 }}>
<div className="admin-modal-header">
<h2 className="admin-modal-title">{backupCodes ? 'Záložní kódy' : 'Nastavení 2FA'}</h2>
</div>
<div className="admin-modal-body">
{backupCodes ? (
<div>
<div className="admin-role-locked-notice" style={{ marginBottom: '1rem' }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" /><line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
Uložte si tyto kódy na bezpečné místo. Každý kód lze použít pouze jednou. Po zavření tohoto okna je již neuvidíte.
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '0.5rem', padding: '1rem', background: 'var(--bg-secondary)', borderRadius: '0.5rem', fontFamily: 'monospace', fontSize: '1rem' }}>
{backupCodes.map((code) => (
<div key={code} style={{ padding: '0.25rem 0.5rem', textAlign: 'center', color: 'var(--text-primary)' }}>{code}</div>
))}
</div>
<div style={{ marginTop: '0.75rem' }}>
<button onClick={() => { navigator.clipboard?.writeText(backupCodes.join('\n')); alert.success('Kódy zkopírovány') }} className="admin-btn admin-btn-secondary admin-btn-sm">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
Kopírovat kódy
</button>
</div>
</div>
) : (
<div>
<p className="text-secondary" style={{ fontSize: '0.875rem', marginBottom: '1rem' }}>
Naskenujte QR kód v autentizační aplikaci (Google Authenticator, Authy, Microsoft Authenticator apod.)
</p>
{totpQrUri && (
<div style={{ textAlign: 'center', marginBottom: '1rem' }}>
<canvas
ref={(canvas) => {
if (canvas && totpQrUri) {
import('../../../utils/qrcode.js').then(({ renderQR }) => renderQR(canvas, totpQrUri))
}
}}
style={{ width: 200, height: 200, borderRadius: '0.5rem', border: '1px solid var(--border-color)' }}
/>
</div>
)}
{totpSecret && (
<div style={{ marginBottom: '1rem' }}>
<label className="admin-form-label" style={{ fontSize: '0.75rem' }}>Nebo zadejte klíč ručně:</label>
<div style={{ padding: '0.5rem 0.75rem', background: 'var(--bg-secondary)', borderRadius: '0.375rem', fontFamily: 'monospace', fontSize: '0.875rem', wordBreak: 'break-all', color: 'var(--text-primary)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '0.5rem' }}>
<span>{totpSecret}</span>
<button onClick={() => { navigator.clipboard?.writeText(totpSecret); alert.success('Klíč zkopírován') }} className="admin-btn-icon" title="Kopírovat" aria-label="Kopírovat" style={{ flexShrink: 0 }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
</button>
</div>
</div>
)}
<div className="admin-form-group">
<label className="admin-form-label">Ověřovací kód z aplikace</label>
<input ref={totpSetupRef} type="text" inputMode="numeric" pattern="[0-9]*" maxLength={6} value={totpCode} onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ''))} placeholder="000000" className="admin-form-input" style={{ textAlign: 'center', fontSize: '1.25rem', letterSpacing: '0.4rem', fontFamily: 'monospace' }} onKeyDown={(e) => { if (e.key === 'Enter' && totpCode.length === 6) { onConfirm2FA() } }} />
</div>
</div>
)}
</div>
<div className="admin-modal-footer">
{backupCodes ? (
<button onClick={() => { setShow2FASetup(false); setBackupCodes(null) }} className="admin-btn admin-btn-primary">
Rozumím, uložil jsem si kódy
</button>
) : (
<>
<button onClick={() => setShow2FASetup(false)} className="admin-btn admin-btn-secondary" disabled={totpSubmitting}>Zrušit</button>
<button onClick={onConfirm2FA} className="admin-btn admin-btn-primary" disabled={totpSubmitting || totpCode.length !== 6}>
{totpSubmitting ? 'Ověřuji...' : 'Aktivovat 2FA'}
</button>
</>
)}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
{/* 2FA Disable Modal */}
<AnimatePresence>
{show2FADisable && (
<motion.div className="admin-modal-overlay" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.2 }}>
<div className="admin-modal-backdrop" onClick={() => setShow2FADisable(false)} />
<motion.div className="admin-modal" initial={{ opacity: 0, scale: 0.95, y: 20 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.95, y: 20 }} transition={{ duration: 0.2 }}>
<div className="admin-modal-header"><h2 className="admin-modal-title">Deaktivovat 2FA</h2></div>
<div className="admin-modal-body">
<p style={{ color: 'var(--text-secondary)', fontSize: '0.875rem', marginBottom: '1rem' }}>
Pro deaktivaci dvoufaktorového ověření zadejte aktuální kód z autentizační aplikace.
</p>
<div className="admin-form-group">
<label className="admin-form-label">Ověřovací kód</label>
<input type="text" inputMode="numeric" pattern="[0-9]*" maxLength={6} value={disableCode} onChange={(e) => setDisableCode(e.target.value.replace(/\D/g, ''))} placeholder="000000" className="admin-form-input" style={{ textAlign: 'center', fontSize: '1.25rem', letterSpacing: '0.4rem', fontFamily: 'monospace' }} onKeyDown={(e) => { if (e.key === 'Enter' && disableCode.length === 6) { onDisable2FA() } }} autoFocus />
</div>
</div>
<div className="admin-modal-footer">
<button onClick={() => setShow2FADisable(false)} className="admin-btn admin-btn-secondary" disabled={totpSubmitting}>Zrušit</button>
<button onClick={onDisable2FA} className="admin-btn admin-btn-primary" disabled={totpSubmitting || disableCode.length !== 6}>
{totpSubmitting ? 'Deaktivuji...' : 'Deaktivovat 2FA'}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</>
)
}

View File

@@ -0,0 +1,335 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { motion, AnimatePresence } from 'framer-motion'
import { useAuth } from '../../context/AuthContext'
import { useAlert } from '../../context/AlertContext'
import { formatKm } from '../../utils/formatters'
import AdminDatePicker from '../AdminDatePicker'
import apiFetch from '../../utils/api'
import useModalLock from '../../hooks/useModalLock'
const API_BASE = '/api/admin'
export default function DashQuickActions({ dashData, punching, onPunch }) {
const { hasPermission } = useAuth()
const alert = useAlert()
const [showTripModal, setShowTripModal] = useState(false)
const [tripSubmitting, setTripSubmitting] = useState(false)
const [tripVehicles, setTripVehicles] = useState([])
const [tripForm, setTripForm] = useState({
vehicle_id: '', trip_date: '', start_km: '', end_km: '',
route_from: '', route_to: '', is_business: 1, notes: ''
})
const [tripErrors, setTripErrors] = useState({})
useModalLock(showTripModal)
const openTripModal = async () => {
setTripForm({
vehicle_id: '', trip_date: new Date().toISOString().split('T')[0],
start_km: '', end_km: '', route_from: '', route_to: '',
is_business: 1, notes: ''
})
setTripErrors({})
setShowTripModal(true)
try {
const response = await apiFetch(`${API_BASE}/trips.php?action=active_vehicles`)
const result = await response.json()
if (result.success) {
setTripVehicles(result.data.vehicles || [])
}
} catch {
// vozidla se nenacetla
}
}
const handleTripVehicleChange = async (vehicleId) => {
setTripForm(prev => ({ ...prev, vehicle_id: vehicleId }))
if (!vehicleId) {
return
}
try {
const response = await apiFetch(`${API_BASE}/trips.php?action=last_km&vehicle_id=${vehicleId}`)
const result = await response.json()
if (result.success) {
setTripForm(prev => ({ ...prev, start_km: result.data.last_km }))
}
} catch {
// last_km se nenacetlo
}
}
const handleTripSubmit = async () => {
const errs = {}
if (!tripForm.vehicle_id) {
errs.vehicle_id = 'Vyberte vozidlo'
}
if (!tripForm.trip_date) {
errs.trip_date = 'Zadejte datum'
}
if (!tripForm.start_km) {
errs.start_km = 'Zadejte počáteční km'
}
if (!tripForm.end_km) {
errs.end_km = 'Zadejte konečný km'
}
if (tripForm.start_km && tripForm.end_km && parseInt(tripForm.end_km) <= parseInt(tripForm.start_km)) {
errs.end_km = 'Musí být větší než počáteční'
}
if (!tripForm.route_from) {
errs.route_from = 'Zadejte místo odjezdu'
}
if (!tripForm.route_to) {
errs.route_to = 'Zadejte místo příjezdu'
}
setTripErrors(errs)
if (Object.keys(errs).length > 0) {
return
}
setTripSubmitting(true)
try {
const response = await apiFetch(`${API_BASE}/trips.php`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(tripForm)
})
const result = await response.json()
if (result.success) {
setShowTripModal(false)
alert.success(result.message)
} else {
alert.error(result.error)
}
} catch {
alert.error('Chyba připojení')
} finally {
setTripSubmitting(false)
}
}
const tripDistance = () => {
const s = parseInt(tripForm.start_km) || 0
const e = parseInt(tripForm.end_km) || 0
return e > s ? e - s : 0
}
const hasOngoingShift = dashData?.my_shift?.has_ongoing
const punchLabel = hasOngoingShift ? 'Zaznamenat odchod' : 'Zaznamenat příchod'
const quickActions = []
if (hasPermission('attendance.record')) {
quickActions.push({
label: punching ? 'Odesílám...' : punchLabel,
color: hasOngoingShift ? 'danger' : 'success',
icon: hasOngoingShift
? <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /><polyline points="16 17 21 12 16 7" /><line x1="21" y1="12" x2="9" y2="12" /></svg>
: <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M9 12l2 2 4-4" /><circle cx="12" cy="12" r="10" /></svg>,
onClick: onPunch,
disabled: punching,
})
}
if (hasPermission('offers.create')) {
quickActions.push({ label: 'Nová nabídka', path: '/offers/new', color: 'info', icon: <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><polyline points="14 2 14 8 20 8" /></svg> })
}
if (hasPermission('trips.record')) {
quickActions.push({
label: 'Přidat jízdu',
color: 'warning',
icon: <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="1" y="3" width="15" height="13" rx="2" /><circle cx="8.5" cy="16" r="2.5" /><circle cx="18.5" cy="16" r="2.5" /><path d="M16 8h4l3 5v3h-7" /></svg>,
onClick: openTripModal,
})
}
if (hasPermission('invoices.create')) {
quickActions.push({ label: 'Vystavit fakturu', path: '/invoices/new', color: 'danger', icon: <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 1v22M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" /></svg> })
}
return (
<>
<motion.div
className="dash-quick-actions"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.15 }}
>
{quickActions.map((action) => action.onClick ? (
<button
key={action.label}
onClick={action.onClick}
disabled={action.disabled}
className={`dash-quick-btn dash-quick-btn-${action.color}`}
>
{action.icon}
<span>{action.label}</span>
</button>
) : (
<Link key={action.label} to={action.path} className={`dash-quick-btn dash-quick-btn-${action.color}`}>
{action.icon}
<span>{action.label}</span>
</Link>
))}
</motion.div>
<AnimatePresence>
{showTripModal && (
<motion.div
className="admin-modal-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-backdrop" onClick={() => setShowTripModal(false)} />
<motion.div
className="admin-modal admin-modal-lg"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">Přidat jízdu</h2>
</div>
<div className="admin-modal-body">
<div className="admin-form">
<div className="admin-form-row">
<div className={`admin-form-group${tripErrors.vehicle_id ? ' has-error' : ''}`}>
<label className="admin-form-label required">Vozidlo</label>
<select
value={tripForm.vehicle_id}
onChange={(e) => {
handleTripVehicleChange(e.target.value)
setTripErrors(prev => ({ ...prev, vehicle_id: undefined }))
}}
className="admin-form-select"
>
<option value="">Vyberte vozidlo</option>
{tripVehicles.map((v) => (
<option key={v.id} value={v.id}>{v.spz} - {v.name}</option>
))}
</select>
{tripErrors.vehicle_id && <span className="admin-form-error">{tripErrors.vehicle_id}</span>}
</div>
<div className={`admin-form-group${tripErrors.trip_date ? ' has-error' : ''}`}>
<label className="admin-form-label required">Datum jízdy</label>
<AdminDatePicker
mode="date"
value={tripForm.trip_date}
onChange={(val) => {
setTripForm(prev => ({ ...prev, trip_date: val }))
setTripErrors(prev => ({ ...prev, trip_date: undefined }))
}}
/>
{tripErrors.trip_date && <span className="admin-form-error">{tripErrors.trip_date}</span>}
</div>
</div>
<div className="admin-form-row admin-form-row-3">
<div className={`admin-form-group${tripErrors.start_km ? ' has-error' : ''}`}>
<label className="admin-form-label required">Počáteční stav km</label>
<input
type="number"
inputMode="numeric"
value={tripForm.start_km}
onChange={(e) => {
setTripForm(prev => ({ ...prev, start_km: e.target.value }))
setTripErrors(prev => ({ ...prev, start_km: undefined }))
}}
className="admin-form-input"
min="0"
/>
{tripErrors.start_km && <span className="admin-form-error">{tripErrors.start_km}</span>}
</div>
<div className={`admin-form-group${tripErrors.end_km ? ' has-error' : ''}`}>
<label className="admin-form-label required">Konečný stav km</label>
<input
type="number"
inputMode="numeric"
value={tripForm.end_km}
onChange={(e) => {
setTripForm(prev => ({ ...prev, end_km: e.target.value }))
setTripErrors(prev => ({ ...prev, end_km: undefined }))
}}
className="admin-form-input"
min="0"
/>
{tripErrors.end_km && <span className="admin-form-error">{tripErrors.end_km}</span>}
</div>
<div className="admin-form-group">
<label className="admin-form-label">Vzdálenost</label>
<input type="text" value={`${formatKm(tripDistance())} km`} className="admin-form-input" readOnly disabled />
</div>
</div>
<div className="admin-form-row">
<div className={`admin-form-group${tripErrors.route_from ? ' has-error' : ''}`}>
<label className="admin-form-label required">Místo odjezdu</label>
<input
type="text"
value={tripForm.route_from}
onChange={(e) => {
setTripForm(prev => ({ ...prev, route_from: e.target.value }))
setTripErrors(prev => ({ ...prev, route_from: undefined }))
}}
className="admin-form-input"
placeholder="Např. Praha"
/>
{tripErrors.route_from && <span className="admin-form-error">{tripErrors.route_from}</span>}
</div>
<div className={`admin-form-group${tripErrors.route_to ? ' has-error' : ''}`}>
<label className="admin-form-label required">Místo příjezdu</label>
<input
type="text"
value={tripForm.route_to}
onChange={(e) => {
setTripForm(prev => ({ ...prev, route_to: e.target.value }))
setTripErrors(prev => ({ ...prev, route_to: undefined }))
}}
className="admin-form-input"
placeholder="Např. Brno"
/>
{tripErrors.route_to && <span className="admin-form-error">{tripErrors.route_to}</span>}
</div>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Typ jízdy</label>
<select
value={tripForm.is_business}
onChange={(e) => setTripForm(prev => ({ ...prev, is_business: parseInt(e.target.value) }))}
className="admin-form-select"
>
<option value={1}>Služební</option>
<option value={0}>Soukromá</option>
</select>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Poznámky</label>
<textarea
value={tripForm.notes}
onChange={(e) => setTripForm(prev => ({ ...prev, notes: e.target.value }))}
className="admin-form-textarea"
rows={2}
placeholder="Volitelné poznámky..."
/>
</div>
</div>
</div>
<div className="admin-modal-footer">
<button type="button" onClick={() => setShowTripModal(false)} className="admin-btn admin-btn-secondary" disabled={tripSubmitting}>
Zrušit
</button>
<button type="button" onClick={handleTripSubmit} className="admin-btn admin-btn-primary" disabled={tripSubmitting}>
{tripSubmitting ? 'Ukládám...' : 'Uložit'}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</>
)
}

View File

@@ -0,0 +1,199 @@
import { useState, useEffect, useCallback } from 'react'
import { motion } from 'framer-motion'
import { useAlert } from '../../context/AlertContext'
import ConfirmModal from '../ConfirmModal'
import useModalLock from '../../hooks/useModalLock'
import apiFetch from '../../utils/api'
import { formatSessionDate } from '../../utils/dashboardHelpers'
const API_BASE = '/api/admin'
function getDeviceIcon(iconType) {
switch (iconType) {
case 'smartphone':
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="5" y="2" width="14" height="20" rx="2" ry="2" /><line x1="12" y1="18" x2="12" y2="18" />
</svg>
)
case 'tablet':
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="4" y="2" width="16" height="20" rx="2" ry="2" /><line x1="12" y1="18" x2="12" y2="18" />
</svg>
)
default:
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
<line x1="8" y1="21" x2="16" y2="21" /><line x1="12" y1="17" x2="12" y2="21" />
</svg>
)
}
}
export default function DashSessions() {
const alert = useAlert()
const [sessions, setSessions] = useState([])
const [sessionsLoading, setSessionsLoading] = useState(true)
const [deleteModal, setDeleteModal] = useState({ isOpen: false, session: null })
const [deleteAllModal, setDeleteAllModal] = useState(false)
const [deleting, setDeleting] = useState(false)
useModalLock(deleteAllModal)
const fetchSessions = useCallback(async () => {
try {
const response = await apiFetch(`${API_BASE}/sessions.php`)
const data = await response.json()
if (data.success) {
setSessions(data.data.sessions || [])
}
} catch {
// session fetch failed silently
} finally {
setSessionsLoading(false)
}
}, [])
useEffect(() => {
fetchSessions()
}, [fetchSessions])
const handleDeleteSession = async () => {
if (!deleteModal.session) {
return
}
const sessionId = deleteModal.session.id
setDeleting(true)
try {
const response = await apiFetch(`${API_BASE}/sessions.php?id=${sessionId}`, { method: 'DELETE' })
const data = await response.json()
if (data.success) {
setDeleteModal({ isOpen: false, session: null })
setSessions(prev => prev.filter(s => s.id !== sessionId))
alert.success('Relace byla ukončena')
} else {
alert.error(data.error || 'Nepodařilo se ukončit relaci')
}
} catch {
alert.error('Chyba připojení')
} finally {
setDeleting(false)
}
}
const handleDeleteAllSessions = async () => {
setDeleting(true)
try {
const response = await apiFetch(`${API_BASE}/sessions.php?action=all`, { method: 'DELETE' })
const data = await response.json()
if (data.success) {
setDeleteAllModal(false)
setSessions(prev => prev.filter(s => s.is_current))
alert.success(data.message || 'Ostatní relace byly ukončeny')
} else {
alert.error(data.error || 'Nepodařilo se ukončit relace')
}
} catch {
alert.error('Chyba připojení')
} finally {
setDeleting(false)
}
}
return (
<>
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.3 }}
>
<div className="admin-card-header" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '0.75rem' }}>
<h2 className="admin-card-title">Přihlášená zařízení</h2>
{sessions.filter(s => !s.is_current).length > 0 && (
<button onClick={() => setDeleteAllModal(true)} className="admin-btn admin-btn-secondary admin-btn-sm">
Odhlásit ostatní
</button>
)}
</div>
<div className="admin-card-body" style={{ padding: 0 }}>
{sessionsLoading && (
<div className="admin-skeleton" style={{ padding: '1rem', gap: '1rem' }}>
{[0, 1, 2].map(i => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line circle" />
<div style={{ flex: 1 }}>
<div className="admin-skeleton-line w-1/2" style={{ marginBottom: '0.5rem' }} />
<div className="admin-skeleton-line w-1/3" style={{ height: '10px' }} />
</div>
</div>
))}
</div>
)}
{!sessionsLoading && sessions.length === 0 && (
<div className="text-secondary" style={{ padding: '1.5rem', textAlign: 'center', fontSize: '0.875rem' }}>
Žádné aktivní relace
</div>
)}
{!sessionsLoading && sessions.length > 0 && (
<div className="sessions-list">
{sessions.map((session) => (
<div key={session.id} className={`session-item ${session.is_current ? 'session-item-current' : ''}`}>
<div className="session-icon">{getDeviceIcon(session.device_info?.icon)}</div>
<div className="session-info">
<div className="session-device">
{session.device_info?.browser} na {session.device_info?.os}
{session.is_current && (
<span className="admin-badge admin-badge-success" style={{ marginLeft: '0.5rem' }}>Aktuální</span>
)}
</div>
<div className="session-meta">
<span>{session.ip_address}</span>
<span className="session-meta-separator">|</span>
<span>{formatSessionDate(session.created_at)}</span>
</div>
</div>
<div className="session-actions">
{!session.is_current && (
<button onClick={() => setDeleteModal({ isOpen: true, session })} className="admin-btn-icon danger" title="Ukončit relaci" aria-label="Ukončit relaci">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /><polyline points="16 17 21 12 16 7" /><line x1="21" y1="12" x2="9" y2="12" />
</svg>
</button>
)}
</div>
</div>
))}
</div>
)}
</div>
</motion.div>
<ConfirmModal
isOpen={deleteModal.isOpen}
onClose={() => setDeleteModal({ isOpen: false, session: null })}
onConfirm={handleDeleteSession}
title="Ukončit relaci"
message={`Opravdu chcete ukončit relaci na zařízení "${deleteModal.session?.device_info?.browser} na ${deleteModal.session?.device_info?.os}"? Toto zařízení bude odhlášeno.`}
confirmText="Ukončit"
cancelText="Zrušit"
type="danger"
loading={deleting}
/>
<ConfirmModal
isOpen={deleteAllModal}
onClose={() => setDeleteAllModal(false)}
onConfirm={handleDeleteAllSessions}
title="Odhlásit ostatní zařízení"
message="Opravdu chcete ukončit všechny ostatní relace? Budete odhlášeni ze všech zařízení kromě tohoto."
confirmText="Odhlásit vše"
cancelText="Zrušit"
type="warning"
loading={deleting}
/>
</>
)
}

View File

@@ -0,0 +1,627 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import DOMPurify from 'dompurify'
import apiFetch from '../utils/api'
import {
formatDate,
formatMinutes,
getLeaveTypeName, getLeaveTypeBadgeClass,
getDatePart, getTimePart,
calcProjectMinutesTotal, calcFormWorkMinutes,
formatTimeOrDatetimePrint, calculateWorkMinutesPrint
} from '../utils/attendanceHelpers'
const API_BASE = '/api/admin'
function renderFundStatus(userData) {
if (userData.overtime > 0) {
return `<span class="leave-badge badge-overtime">+${userData.overtime}h přesčas</span>`
}
if (userData.missing > 0) {
return `<span style="color:#dc2626">${userData.missing}h</span>`
}
return '<span style="color:#16a34a">splněno</span>'
}
function buildProjectLogsHtml(record) {
if (record.project_logs && record.project_logs.length > 0) {
return record.project_logs.map(log => {
let h, m
if (log.hours !== null && log.hours !== undefined) {
h = parseInt(log.hours) || 0
m = parseInt(log.minutes) || 0
} else if (log.started_at && log.ended_at) {
const mins = Math.max(0, Math.floor((new Date(log.ended_at) - new Date(log.started_at)) / 60000))
h = Math.floor(mins / 60)
m = mins % 60
} else {
h = 0
m = 0
}
return `<div>${log.project_name || `#${log.project_id}`} (${h}:${String(m).padStart(2, '0')}h)</div>`
}).join('')
}
return record.project_name || '—'
}
function buildUserSectionHtml(userId, userData, printData) {
const leaveHtml = printData.leave_balances[userId]
? buildLeaveSummaryHtml(userId, userData, printData)
: ''
const recordRows = userData.records.map(record => {
const leaveType = record.leave_type || 'work'
const isLeave = leaveType !== 'work'
const workMinutes = calculateWorkMinutesPrint(record)
const hours = Math.floor(workMinutes / 60)
const mins = workMinutes % 60
const breakCell = (isLeave || !record.break_start || !record.break_end)
? '—'
: `${formatTimeOrDatetimePrint(record.break_start, record.shift_date)} - ${formatTimeOrDatetimePrint(record.break_end, record.shift_date)}`
return `<tr>
<td>${formatDate(record.shift_date)}</td>
<td><span class="leave-badge ${getLeaveTypeBadgeClass(leaveType)}">${getLeaveTypeName(leaveType)}</span></td>
<td class="text-center">${isLeave ? '—' : formatTimeOrDatetimePrint(record.arrival_time, record.shift_date)}</td>
<td class="text-center">${breakCell}</td>
<td class="text-center">${isLeave ? '—' : formatTimeOrDatetimePrint(record.departure_time, record.shift_date)}</td>
<td class="text-center">${workMinutes > 0 ? `${hours}:${String(mins).padStart(2, '0')}` : '—'}</td>
<td style="font-size:8px">${buildProjectLogsHtml(record)}</td>
<td>${record.notes || ''}</td>
</tr>`
}).join('')
const fundRow = userData.fund !== null
? `<tr>
<td colspan="6" class="text-right">Fond měsíce:</td>
<td class="text-center">${userData.covered}h / ${userData.fund}h</td>
<td colspan="2">${renderFundStatus(userData)}</td>
</tr>`
: ''
return `<div class="user-section">
<div class="user-header">
<h3>${userData.name}</h3>
<span class="total">Odpracováno: ${formatMinutes(userData.minutes)} h</span>
</div>
${leaveHtml}
<table>
<thead><tr>
<th style="width:70px">Datum</th>
<th style="width:70px">Typ</th>
<th class="text-center" style="width:70px">Příchod</th>
<th class="text-center" style="width:90px">Pauza</th>
<th class="text-center" style="width:70px">Odchod</th>
<th class="text-center" style="width:80px">Hodiny</th>
<th>Projekty</th>
<th>Poznámka</th>
</tr></thead>
<tbody>${recordRows}</tbody>
<tfoot>
<tr>
<td colspan="6" class="text-right">Odpracováno:</td>
<td class="text-center">${formatMinutes(userData.minutes)} h</td>
<td colspan="2"></td>
</tr>
${fundRow}
</tfoot>
</table>
</div>`
}
function buildLeaveSummaryHtml(userId, userData, printData) {
const bal = printData.leave_balances[userId]
let parts = `<strong>Dovolená ${printData.year}:</strong> Zbývá ${bal.vacation_remaining.toFixed(1)}h z ${bal.vacation_total}h`
if (userData.vacation_hours > 0) {
parts += ` | <span class="leave-badge badge-vacation">Tento měsíc: ${userData.vacation_hours}h</span>`
}
if (userData.sick_hours > 0) {
parts += ` | <span class="leave-badge badge-sick">Nemoc: ${userData.sick_hours}h</span>`
}
if (userData.holiday_hours > 0) {
parts += ` | <span class="leave-badge badge-holiday">Svátek: ${userData.holiday_hours}h</span>`
}
if (userData.overtime > 0) {
parts += ` | <span class="leave-badge badge-overtime">Přesčas: +${userData.overtime}h</span>`
}
return `<div class="leave-summary">${parts}</div>`
}
export default function useAttendanceAdmin({ alert }) {
const [loading, setLoading] = useState(true)
const [month, setMonth] = useState(() => {
const now = new Date()
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
})
const [filterUserId, setFilterUserId] = useState('')
const [data, setData] = useState({
records: [],
users: [],
user_totals: {},
leave_balances: {}
})
const [showBulkModal, setShowBulkModal] = useState(false)
const [bulkSubmitting, setBulkSubmitting] = useState(false)
const [bulkForm, setBulkForm] = useState({
month: '',
user_ids: [],
arrival_time: '08:00',
departure_time: '16:30',
break_start_time: '12:00',
break_end_time: '12:30'
})
const [showCreateModal, setShowCreateModal] = useState(false)
const today = new Date().toISOString().split('T')[0]
const [createForm, setCreateForm] = useState({
user_id: '',
shift_date: today,
leave_type: 'work',
leave_hours: 8,
arrival_date: today,
arrival_time: '',
break_start_date: today,
break_start_time: '',
break_end_date: today,
break_end_time: '',
departure_date: today,
departure_time: '',
notes: ''
})
const [showEditModal, setShowEditModal] = useState(false)
const [editingRecord, setEditingRecord] = useState(null)
const [editForm, setEditForm] = useState({
shift_date: '',
leave_type: 'work',
leave_hours: 8,
arrival_date: '',
arrival_time: '',
break_start_date: '',
break_start_time: '',
break_end_date: '',
break_end_time: '',
departure_date: '',
departure_time: '',
notes: ''
})
const [deleteConfirm, setDeleteConfirm] = useState({ show: false, record: null })
const [projectList, setProjectList] = useState([])
const [createProjectLogs, setCreateProjectLogs] = useState([])
const [editProjectLogs, setEditProjectLogs] = useState([])
const printRef = useRef(null)
useEffect(() => {
const loadProjects = async () => {
try {
const response = await apiFetch(`${API_BASE}/attendance.php?action=projects`)
const result = await response.json()
if (result.success) setProjectList(result.data.projects || [])
} catch { /* silent */ }
}
loadProjects()
}, [])
const fetchData = useCallback(async (showLoading = true) => {
if (showLoading) setLoading(true)
try {
let url = `${API_BASE}/attendance.php?action=admin&month=${month}`
if (filterUserId) {
url += `&user_id=${filterUserId}`
}
const response = await apiFetch(url)
if (response.status === 401) return
const result = await response.json()
if (result.success) {
setData(result.data)
}
} catch {
alert.error('Nepodařilo se načíst data')
} finally {
if (showLoading) setLoading(false)
}
}, [month, filterUserId, alert])
useEffect(() => {
fetchData()
}, [fetchData])
// --- Create modal ---
const openCreateModal = () => {
const todayDate = new Date().toISOString().split('T')[0]
setCreateForm({
user_id: '',
shift_date: todayDate,
leave_type: 'work',
leave_hours: 8,
arrival_date: todayDate,
arrival_time: '',
break_start_date: todayDate,
break_start_time: '',
break_end_date: todayDate,
break_end_time: '',
departure_date: todayDate,
departure_time: '',
notes: '',
project_id: ''
})
setCreateProjectLogs([])
setShowCreateModal(true)
}
const handleCreateShiftDateChange = (newDate) => {
setCreateForm({
...createForm,
shift_date: newDate,
arrival_date: newDate,
break_start_date: newDate,
break_end_date: newDate,
departure_date: newDate
})
}
const handleCreateSubmit = async () => {
if (!createForm.user_id || !createForm.shift_date) {
alert.error('Vyplňte zaměstnance a datum směny')
return
}
const filteredCreateLogs = createProjectLogs.filter(l => l.project_id)
if (filteredCreateLogs.length > 0 && createForm.leave_type === 'work') {
if (!validateProjectLogs(filteredCreateLogs, createForm)) return
}
try {
const payload = { ...createForm }
if (filteredCreateLogs.length > 0 && createForm.leave_type === 'work') {
payload.project_logs = filteredCreateLogs
}
const response = await apiFetch(`${API_BASE}/attendance.php?action=create`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
const result = await response.json()
if (result.success) {
setShowCreateModal(false)
await fetchData(false)
await new Promise(resolve => setTimeout(resolve, 300))
alert.success(result.message)
} else {
alert.error(result.error)
}
} catch {
alert.error('Chyba připojení')
}
}
const validateProjectLogs = (logs, formData) => {
const totalWork = calcFormWorkMinutes(formData)
const totalProject = calcProjectMinutesTotal(logs)
if (totalWork > 0 && totalProject !== totalWork) {
const wH = Math.floor(totalWork / 60)
const wM = totalWork % 60
const pH = Math.floor(totalProject / 60)
const pM = totalProject % 60
alert.error(`Součet hodin projektů (${pH}h ${pM}m) neodpovídá odpracovanému času (${wH}h ${wM}m)`)
return false
}
return true
}
// --- Bulk modal ---
const openBulkModal = () => {
setBulkForm({
month: month,
user_ids: data.users.map(u => String(u.id)),
arrival_time: '08:00',
departure_time: '16:30',
break_start_time: '12:00',
break_end_time: '12:30'
})
setShowBulkModal(true)
}
const toggleBulkUser = (userId) => {
const uid = String(userId)
setBulkForm(prev => ({
...prev,
user_ids: prev.user_ids.includes(uid)
? prev.user_ids.filter(u => u !== uid)
: [...prev.user_ids, uid]
}))
}
const toggleAllBulkUsers = () => {
const allIds = data.users.map(u => String(u.id))
setBulkForm(prev => ({
...prev,
user_ids: prev.user_ids.length === allIds.length ? [] : allIds
}))
}
const handleBulkSubmit = async () => {
if (!bulkForm.month) {
alert.error('Vyberte měsíc')
return
}
if (bulkForm.user_ids.length === 0) {
alert.error('Vyberte alespoň jednoho zaměstnance')
return
}
setBulkSubmitting(true)
try {
const response = await apiFetch(`${API_BASE}/attendance.php?action=bulk_attendance`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(bulkForm)
})
const result = await response.json()
if (result.success) {
setShowBulkModal(false)
await fetchData(false)
await new Promise(resolve => setTimeout(resolve, 300))
alert.success(result.message)
} else {
alert.error(result.error)
}
} catch {
alert.error('Chyba připojení')
} finally {
setBulkSubmitting(false)
}
}
// --- Edit modal ---
const openEditModal = (record) => {
setEditingRecord(record)
setEditForm({
shift_date: record.shift_date,
leave_type: record.leave_type || 'work',
leave_hours: record.leave_hours || 8,
arrival_date: getDatePart(record.arrival_time) || record.shift_date,
arrival_time: getTimePart(record.arrival_time),
break_start_date: getDatePart(record.break_start) || record.shift_date,
break_start_time: getTimePart(record.break_start),
break_end_date: getDatePart(record.break_end) || record.shift_date,
break_end_time: getTimePart(record.break_end),
departure_date: getDatePart(record.departure_time) || record.shift_date,
departure_time: getTimePart(record.departure_time),
notes: record.notes || '',
project_id: record.project_id || ''
})
const logs = (record.project_logs || []).map(l => {
if (l.hours !== null && l.hours !== undefined) {
return {
project_id: String(l.project_id),
hours: String(l.hours),
minutes: String(l.minutes || 0)
}
}
if (l.started_at && l.ended_at) {
const mins = Math.max(0, Math.floor((new Date(l.ended_at) - new Date(l.started_at)) / 60000))
return {
project_id: String(l.project_id),
hours: String(Math.floor(mins / 60)),
minutes: String(mins % 60)
}
}
return { project_id: String(l.project_id), hours: '', minutes: '' }
})
setEditProjectLogs(logs)
setShowEditModal(true)
}
const handleEditSubmit = async () => {
const isWork = (editForm.leave_type || 'work') === 'work'
const filteredEditLogs = isWork ? editProjectLogs.filter(l => l.project_id) : []
if (filteredEditLogs.length > 0) {
if (!validateProjectLogs(filteredEditLogs, editForm)) return
}
try {
const payload = { ...editForm }
payload.project_logs = filteredEditLogs
const response = await apiFetch(`${API_BASE}/attendance.php?id=${editingRecord.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
const result = await response.json()
if (result.success) {
setShowEditModal(false)
await fetchData(false)
await new Promise(resolve => setTimeout(resolve, 300))
alert.success(result.message)
} else {
alert.error(result.error)
}
} catch {
alert.error('Chyba připojení')
}
}
// --- Delete ---
const handleDelete = async () => {
if (!deleteConfirm.record) return
try {
const response = await apiFetch(`${API_BASE}/attendance.php?id=${deleteConfirm.record.id}`, {
method: 'DELETE',
credentials: 'include'
})
const result = await response.json()
if (result.success) {
setDeleteConfirm({ show: false, record: null })
await fetchData(false)
alert.success(result.message)
} else {
alert.error(result.error)
}
} catch {
alert.error('Chyba připojení')
}
}
// --- Print ---
const handlePrint = async () => {
try {
let url = `${API_BASE}/attendance.php?action=print&month=${month}`
if (filterUserId) {
url += `&user_id=${filterUserId}`
}
const response = await apiFetch(url)
if (response.status === 401) return
const result = await response.json()
if (result.success) {
openPrintWindow(result.data)
}
} catch {
alert.error('Nepodařilo se připravit tisk')
}
}
const openPrintWindow = (pData) => {
const userSections = Object.entries(pData.user_totals)
.map(([uid, uData]) => buildUserSectionHtml(uid, uData, pData))
.join('')
const emptyMsg = Object.keys(pData.user_totals).length === 0
? '<p style="text-align:center;padding:20px">Za vybrané období nejsou žádné záznamy.</p>'
: ''
const filterNote = pData.selected_user_name
? `<div class="filters">Zaměstnanec: ${pData.selected_user_name}</div>`
: ''
const bodyContent = buildPrintHtml(pData, userSections, emptyMsg, filterNote)
const printWindow = window.open('', '_blank')
if (printWindow) {
printWindow.document.open()
printWindow.document.write(DOMPurify.sanitize(bodyContent, { WHOLE_DOCUMENT: true }))
printWindow.document.close()
printWindow.onload = () => printWindow.print()
}
}
const hasData = Object.keys(data.user_totals).length > 0
return {
loading, month, setMonth,
filterUserId, setFilterUserId,
data, hasData,
showBulkModal, setShowBulkModal,
bulkSubmitting, bulkForm, setBulkForm,
showCreateModal, setShowCreateModal,
createForm, setCreateForm,
showEditModal, setShowEditModal,
editingRecord, editForm, setEditForm,
deleteConfirm, setDeleteConfirm,
projectList,
createProjectLogs, setCreateProjectLogs,
editProjectLogs, setEditProjectLogs,
printRef,
openCreateModal, handleCreateShiftDateChange, handleCreateSubmit,
openBulkModal, toggleBulkUser, toggleAllBulkUsers, handleBulkSubmit,
openEditModal, handleEditSubmit,
handleDelete, handlePrint
}
}
function buildPrintHtml(pData, userSections, emptyMsg, filterNote) {
return `<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Docházka - ${pData.month_name}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
font-size: 11px; line-height: 1.4; color: #000; background: #fff; padding: 15mm;
}
.print-header {
display: flex; justify-content: space-between; align-items: flex-start;
margin-bottom: 20px; padding-bottom: 15px; border-bottom: 2px solid #333;
}
.print-header-left { display: flex; align-items: center; gap: 12px; }
.print-logo { height: 40px; width: auto; }
.print-header-text { text-align: left; }
.print-header-right { text-align: right; }
.print-header h1 { font-size: 18px; font-weight: 700; margin-bottom: 3px; }
.print-header .company { font-size: 11px; color: #666; }
.print-header .period { font-size: 13px; font-weight: 600; color: #333; margin-bottom: 2px; }
.print-header .filters { font-size: 10px; color: #666; }
.print-header .generated { font-size: 9px; color: #888; margin-top: 5px; }
.user-section { margin-bottom: 25px; page-break-inside: avoid; }
.user-header {
background: #f5f5f5; border: 1px solid #ddd; padding: 10px 15px;
margin-bottom: 10px; display: flex; justify-content: space-between; align-items: center;
}
.user-header h3 { font-size: 13px; font-weight: 600; }
.user-header .total { font-size: 12px; font-weight: 600; }
.user-section table { width: 100%; border-collapse: collapse; margin-bottom: 15px; }
.user-section th, .user-section td { border: 1px solid #333; padding: 6px 8px; text-align: left; }
.user-section th { background: #333; color: #fff; font-weight: 600; font-size: 10px; text-transform: uppercase; }
.user-section td { font-size: 10px; }
.user-section tr:nth-child(even) { background: #f9f9f9; }
.text-center { text-align: center; }
.text-right { text-align: right; }
.user-section tfoot td { background: #eee; font-weight: 600; }
.leave-badge { display: inline-block; padding: 2px 6px; border-radius: 3px; font-size: 9px; font-weight: 500; }
.badge-vacation { background: #dbeafe; color: #1d4ed8; }
.badge-sick { background: #fee2e2; color: #dc2626; }
.badge-holiday { background: #dcfce7; color: #16a34a; }
.badge-unpaid { background: #f3f4f6; color: #6b7280; }
.badge-overtime { background: #fef3c7; color: #d97706; }
.leave-summary {
margin-top: 10px; padding: 8px 15px; background: #f9f9f9;
border: 1px solid #ddd; font-size: 10px;
}
.print-wrapper-table { width: 100%; border-collapse: collapse; border: none; }
.print-wrapper-table > thead > tr > td,
.print-wrapper-table > tbody > tr > td { padding: 0; border: none; background: none; }
@media print {
body { padding: 0; margin: 0; }
@page { size: A4 portrait; margin: 10mm; }
.user-section { page-break-inside: avoid; }
}
</style>
</head>
<body>
<table class="print-wrapper-table">
<thead><tr><td>
<div class="print-header">
<div class="print-header-left">
<img src="/images/logo-light.png" alt="BOHA" class="print-logo" />
<div class="print-header-text">
<h1>EVIDENCE DOCHÁZKY</h1>
<div class="company">BOHA Automation s.r.o.</div>
</div>
</div>
<div class="print-header-right">
<div class="period">${pData.month_name}</div>
${filterNote}
<div class="generated">Vygenerováno: ${new Date().toLocaleString('cs-CZ')}</div>
</div>
</div>
</td></tr></thead>
<tbody><tr><td>
${userSections}
${emptyMsg}
</td></tr></tbody>
</table>
</body>
</html>`
}

View File

@@ -0,0 +1,16 @@
import { useState, useEffect } from 'react'
/**
* Debounce hook - zpozdi zmenu hodnoty o zadany cas.
* Pouziti: const debouncedSearch = useDebounce(search, 300)
*/
export default function useDebounce(value, delay = 300) {
const [debouncedValue, setDebouncedValue] = useState(value)
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay)
return () => clearTimeout(timer)
}, [value, delay])
return debouncedValue
}

View File

@@ -0,0 +1,53 @@
import { useEffect, useRef } from 'react'
const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
/**
* Focus trap pro modaly - drzi focus uvnitr prvku.
* Vraci ref ktery se pripoji na modal kontejner.
*/
export default function useFocusTrap(isOpen) {
const ref = useRef(null)
useEffect(() => {
if (!isOpen || !ref.current) { return }
const container = ref.current
const previouslyFocused = document.activeElement
// Focus prvni focusable prvek v modalu
const focusable = container.querySelectorAll(FOCUSABLE)
if (focusable.length > 0) {
focusable[0].focus()
}
const handleKeyDown = (e) => {
if (e.key !== 'Tab') { return }
const nodes = container.querySelectorAll(FOCUSABLE)
if (nodes.length === 0) { return }
const first = nodes[0]
const last = nodes[nodes.length - 1]
if (e.shiftKey && document.activeElement === first) {
e.preventDefault()
last.focus()
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault()
first.focus()
}
}
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('keydown', handleKeyDown)
if (previouslyFocused && typeof previouslyFocused.focus === 'function') {
previouslyFocused.focus()
}
}
}, [isOpen])
return ref
}

View File

@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback, useRef } from 'react' import { useState, useEffect, useCallback, useRef } from 'react'
import { useAlert } from '../context/AlertContext' import { useAlert } from '../context/AlertContext'
import apiFetch from '../utils/api' import apiFetch from '../utils/api'
import useDebounce from './useDebounce'
const API_BASE = '/api/admin' const API_BASE = '/api/admin'
@@ -13,50 +14,59 @@ const API_BASE = '/api/admin'
* @param {string} opts.search - Hledany text * @param {string} opts.search - Hledany text
* @param {string} opts.sort - Sloupec pro razeni * @param {string} opts.sort - Sloupec pro razeni
* @param {string} opts.order - ASC/DESC * @param {string} opts.order - ASC/DESC
* @param {number} [opts.page] - Cislo stranky (1-based)
* @param {number} [opts.perPage] - Pocet zaznamu na stranku
* @param {string} [opts.errorMsg] - Chybova zprava pri neuspechu * @param {string} [opts.errorMsg] - Chybova zprava pri neuspechu
*/ */
export default function useListData(endpoint, { dataKey, search, sort, order, extraParams, errorMsg = 'Nepodařilo se načíst data' } = {}) { export default function useListData(endpoint, { dataKey, search, sort, order, page, perPage, extraParams, errorMsg = 'Nepodařilo se načíst data' } = {}) {
const alert = useAlert() const alert = useAlert()
const [items, setItems] = useState([]) const [items, setItems] = useState([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [pagination, setPagination] = useState(null)
const abortRef = useRef(null) const abortRef = useRef(null)
const extraParamsStr = extraParams ? JSON.stringify(extraParams) : '' const extraParamsStr = extraParams ? JSON.stringify(extraParams) : ''
const debouncedSearch = useDebounce(search, 300)
const fetchData = useCallback(async () => { const fetchData = useCallback(async () => {
if (abortRef.current) abortRef.current.abort() if (abortRef.current) { abortRef.current.abort() }
const controller = new AbortController() const controller = new AbortController()
abortRef.current = controller abortRef.current = controller
try { try {
const params = new URLSearchParams() const params = new URLSearchParams()
if (search) params.set('search', search) if (debouncedSearch) { params.set('search', debouncedSearch) }
if (sort) params.set('sort', sort) if (sort) { params.set('sort', sort) }
if (order) params.set('order', order) if (order) { params.set('order', order) }
if (page) { params.set('page', page) }
if (perPage) { params.set('per_page', perPage) }
if (extraParamsStr) { if (extraParamsStr) {
const extra = JSON.parse(extraParamsStr) const extra = JSON.parse(extraParamsStr)
Object.entries(extra).forEach(([k, v]) => { if (v) params.set(k, v) }) Object.entries(extra).forEach(([k, v]) => { if (v) { params.set(k, v) } })
} }
const response = await apiFetch(`${API_BASE}/${endpoint}?${params}`, { signal: controller.signal }) const response = await apiFetch(`${API_BASE}/${endpoint}?${params}`, { signal: controller.signal })
if (response.status === 401) return if (response.status === 401) { return }
const result = await response.json() const result = await response.json()
if (result.success) { if (result.success) {
setItems(result.data[dataKey] || []) setItems(result.data[dataKey] || [])
if (result.data.pagination) {
setPagination(result.data.pagination)
}
} else { } else {
alert.error(result.error || errorMsg) alert.error(result.error || errorMsg)
} }
} catch (err) { } catch (err) {
if (err.name === 'AbortError') return if (err.name === 'AbortError') { return }
alert.error('Chyba připojení') alert.error('Chyba připojení')
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, [alert, endpoint, dataKey, search, sort, order, extraParamsStr, errorMsg]) }, [alert, endpoint, dataKey, debouncedSearch, sort, order, page, perPage, extraParamsStr, errorMsg])
useEffect(() => { useEffect(() => {
fetchData() fetchData()
return () => { if (abortRef.current) abortRef.current.abort() } return () => { if (abortRef.current) { abortRef.current.abort() } }
}, [fetchData]) }, [fetchData])
return { items, setItems, loading, refetch: fetchData } return { items, setItems, loading, pagination, refetch: fetchData }
} }

View File

@@ -0,0 +1,437 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import apiFetch from '../utils/api'
const API_BASE = '/api/admin'
let _keyCounter = 0
export const emptyItem = () => ({
_key: `item-${++_keyCounter}`,
description: '',
item_description: '',
quantity: 1,
unit: '',
unit_price: 0,
is_included_in_total: true
})
export const emptySection = () => ({
_key: `sec-${++_keyCounter}`,
title: '',
title_cz: '',
content: ''
})
export function assignItemKeys(items) {
return items.map(i => ({ ...i, _key: i._key || `item-${++_keyCounter}` }))
}
export function assignSectionKeys(sections) {
return sections.map(s => ({ ...s, _key: s._key || `sec-${++_keyCounter}` }))
}
const DRAFT_KEY = 'boha_offer_draft'
const initialForm = {
quotation_number: '',
project_code: '',
customer_id: null,
customer_name: '',
created_at: new Date().toISOString().split('T')[0],
valid_until: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
currency: 'EUR',
language: 'EN',
vat_rate: 21,
apply_vat: false,
exchange_rate: '',
exchange_rate_date: '',
scope_title: '',
scope_description: ''
}
export default function useOfferForm({ id, isEdit, alert, navigate }) {
const [loading, setLoading] = useState(isEdit)
const [saving, setSaving] = useState(false)
const [errors, setErrors] = useState({})
const [customers, setCustomers] = useState([])
const [itemTemplates, setItemTemplates] = useState([])
const [scopeTemplates, setScopeTemplates] = useState([])
const [form, setForm] = useState({ ...initialForm })
const [items, setItems] = useState([emptyItem()])
const [sections, setSections] = useState([])
const [orderInfo, setOrderInfo] = useState(null)
const [offerStatus, setOfferStatus] = useState('active')
const [draftSavedAt, setDraftSavedAt] = useState(null)
const draftDataRef = useRef({ form, items, sections })
const draftRestoredRef = useRef(false)
// Fetch customers + templates on mount
useEffect(() => {
const fetchMeta = async () => {
try {
const [custRes, itemTplRes, scopeTplRes] = await Promise.all([
apiFetch(`${API_BASE}/customers.php`),
apiFetch(`${API_BASE}/offers-templates.php?action=items`),
apiFetch(`${API_BASE}/offers-templates.php?action=scopes`)
])
const custData = await custRes.json()
const itemTplData = await itemTplRes.json()
const scopeTplData = await scopeTplRes.json()
if (custData.success) setCustomers(custData.data.customers)
if (itemTplData.success) setItemTemplates(itemTplData.data.templates)
if (scopeTplData.success) setScopeTemplates(scopeTplData.data.templates)
} catch { /* silent */ }
}
fetchMeta()
}, [])
// Restore draft on mount (new offers only)
useEffect(() => {
if (isEdit) return
try {
const raw = localStorage.getItem(DRAFT_KEY)
if (!raw) return
const draft = JSON.parse(raw)
if (!draft || typeof draft !== 'object' || !draft.form || !Array.isArray(draft.items)) {
localStorage.removeItem(DRAFT_KEY)
return
}
const { form: dForm, items: dItems, sections: dSections, savedAt } = draft
setForm(prev => ({
...prev,
project_code: dForm.project_code ?? prev.project_code,
customer_id: dForm.customer_id ?? prev.customer_id,
customer_name: dForm.customer_name ?? prev.customer_name,
created_at: dForm.created_at ?? prev.created_at,
valid_until: dForm.valid_until ?? prev.valid_until,
currency: dForm.currency ?? prev.currency,
language: dForm.language ?? prev.language,
vat_rate: dForm.vat_rate ?? prev.vat_rate,
apply_vat: dForm.apply_vat ?? prev.apply_vat,
exchange_rate: dForm.exchange_rate ?? prev.exchange_rate,
exchange_rate_date: dForm.exchange_rate_date ?? prev.exchange_rate_date,
scope_title: dForm.scope_title ?? prev.scope_title,
scope_description: dForm.scope_description ?? prev.scope_description,
}))
if (dItems.length) setItems(assignItemKeys(dItems))
if (Array.isArray(dSections) && dSections.length) setSections(assignSectionKeys(dSections))
draftRestoredRef.current = true
if (savedAt) setDraftSavedAt(new Date(savedAt))
} catch {
try { localStorage.removeItem(DRAFT_KEY) } catch { /* ignore */ }
}
}, [isEdit])
useEffect(() => {
draftDataRef.current = { form, items, sections }
}, [form, items, sections])
// Auto-save draft (jen nove nabidky)
useEffect(() => {
if (isEdit) return
const timer = setTimeout(() => {
try {
const { form: f, items: it, sections: sc } = draftDataRef.current
const { quotation_number: _qn, ...formWithoutNumber } = f
const savedAt = new Date().toISOString()
localStorage.setItem(DRAFT_KEY, JSON.stringify({ form: formWithoutNumber, items: it, sections: sc, savedAt }))
setDraftSavedAt(new Date(savedAt))
} catch { /* ignore */ }
}, 500)
return () => clearTimeout(timer)
}, [form, items, sections, isEdit])
// Fetch next number + defaults / detail
useEffect(() => {
if (!isEdit) {
const fetchNextNumber = async () => {
try {
const response = await apiFetch(`${API_BASE}/offers.php?action=next_number`)
const result = await response.json()
if (result.success) {
setForm(prev => ({ ...prev, quotation_number: result.data.number }))
}
} catch { /* ignore */ }
}
const fetchDefaults = async () => {
try {
const response = await apiFetch(`${API_BASE}/company-settings.php`)
const result = await response.json()
if (result.success && !draftRestoredRef.current) {
const s = result.data
setForm(prev => ({
...prev,
currency: s.default_currency || prev.currency,
vat_rate: s.default_vat_rate || prev.vat_rate
}))
}
} catch { /* ignore */ }
}
fetchNextNumber()
fetchDefaults()
return
}
const fetchDetail = async () => {
try {
const response = await apiFetch(`${API_BASE}/offers.php?action=detail&id=${id}`)
if (response.status === 401) return
const result = await response.json()
if (result.success) {
populateFromDetail(result.data)
} else {
alert.error(result.error || 'Nepodařilo se načíst nabídku')
navigate('/offers')
}
} catch {
alert.error('Chyba připojení')
navigate('/offers')
} finally {
setLoading(false)
}
}
fetchDetail()
}, [isEdit, id, alert, navigate])
const populateFromDetail = (q) => {
setForm({
quotation_number: q.quotation_number || '',
project_code: q.project_code || '',
customer_id: q.customer_id || null,
customer_name: q.customer_name || '',
created_at: (q.created_at || '').substring(0, 10),
valid_until: (q.valid_until || '').substring(0, 10),
currency: q.currency || 'EUR',
language: q.language || 'EN',
vat_rate: q.vat_rate || 21,
apply_vat: Boolean(q.apply_vat),
exchange_rate: q.exchange_rate || '',
exchange_rate_date: q.exchange_rate_date || '',
scope_title: q.scope_title || '',
scope_description: q.scope_description || ''
})
if (q.items?.length) {
setItems(q.items.map(item => ({
_key: `item-${++_keyCounter}`,
description: item.description || '',
item_description: item.item_description || '',
quantity: Number(item.quantity) || 1,
unit: item.unit || '',
unit_price: Number(item.unit_price) || 0,
is_included_in_total: Boolean(item.is_included_in_total)
})))
}
if (q.sections?.length) {
setItems(prev => prev) // no-op, keep items
setSections(q.sections.map(s => ({
_key: `sec-${++_keyCounter}`,
title: s.title || '',
title_cz: s.title_cz || '',
content: s.content || ''
})))
}
setOrderInfo(q.order || null)
setOfferStatus(q.status || 'active')
}
// Calculated totals
const totals = useMemo(() => {
const subtotal = items.reduce((sum, item) => {
if (item.is_included_in_total) {
return sum + (Number(item.quantity) || 0) * (Number(item.unit_price) || 0)
}
return sum
}, 0)
const vatAmount = form.apply_vat ? subtotal * ((Number(form.vat_rate) || 0) / 100) : 0
return { subtotal, vatAmount, total: subtotal + vatAmount }
}, [items, form.apply_vat, form.vat_rate])
// Draft helpers
const clearDraft = useCallback(() => {
try { localStorage.removeItem(DRAFT_KEY) } catch { /* ignore */ }
setDraftSavedAt(null)
}, [])
const draftSavedAtLabel = useMemo(() => {
if (!draftSavedAt) return null
return draftSavedAt.toLocaleTimeString('cs-CZ', { hour: '2-digit', minute: '2-digit' })
}, [draftSavedAt])
// Form handlers
const updateForm = (field, value) => setForm(prev => ({ ...prev, [field]: value }))
const selectCustomer = (customer) => {
setForm(prev => ({
...prev,
customer_id: customer.id,
customer_name: customer.name
}))
setErrors(prev => ({ ...prev, customer_id: undefined }))
}
const clearCustomer = () => {
setForm(prev => ({ ...prev, customer_id: null, customer_name: '' }))
}
// Items handlers
const updateItem = (index, field, value) => {
setItems(prev => prev.map((item, i) => i === index ? { ...item, [field]: value } : item))
}
const addItem = () => setItems(prev => [...prev, emptyItem()])
const removeItem = (index) => {
setItems(prev => prev.length > 1 ? prev.filter((_, i) => i !== index) : prev)
}
const addItemFromTemplate = (template) => {
setItems(prev => [...prev, {
_key: `item-${++_keyCounter}`,
description: template.name || '',
item_description: template.description || '',
quantity: 1,
unit: '',
unit_price: Number(template.default_price) || 0,
is_included_in_total: true
}])
}
// Sections handlers
const addSection = () => setSections(prev => [...prev, emptySection()])
const removeSection = (index) => {
setSections(prev => prev.filter((_, i) => i !== index))
}
const updateSection = (index, field, value) => {
setSections(prev => prev.map((s, i) => i === index ? { ...s, [field]: value } : s))
}
const moveSection = (index, direction) => {
setSections(prev => {
const newSections = [...prev]
const target = index + direction
if (target < 0 || target >= newSections.length) return prev
;[newSections[index], newSections[target]] = [newSections[target], newSections[index]]
return newSections
})
}
const loadScopeTemplate = async (template) => {
try {
const response = await apiFetch(`${API_BASE}/offers-templates.php?action=scope_detail&id=${template.id}`)
const result = await response.json()
if (result.success) {
const tpl = result.data
setForm(prev => ({
...prev,
scope_description: tpl.description || prev.scope_description
}))
if (tpl.sections) {
const newSections = tpl.sections.map(s => ({
_key: `sec-${++_keyCounter}`,
title: s.title || '',
title_cz: s.title_cz || '',
content: s.content || ''
}))
setSections(prev => [...prev, ...newSections])
}
alert.success(`Načtena šablona "${template.name}"`)
}
} catch {
alert.error('Nepodařilo se načíst šablonu')
}
}
// Save
const handleSave = async () => {
const newErrors = {}
if (!form.customer_id) newErrors.customer_id = 'Vyberte zákazníka'
if (!form.created_at) newErrors.created_at = 'Zadejte datum'
if (!form.valid_until) newErrors.valid_until = 'Zadejte datum'
if (items.length === 0 || items.every(i => !i.description.trim())) {
newErrors.items = 'Přidejte alespoň jednu položku'
}
setErrors(newErrors)
if (Object.keys(newErrors).length > 0) return
setSaving(true)
try {
const payload = buildPayload()
const url = isEdit
? `${API_BASE}/offers.php?id=${id}`
: `${API_BASE}/offers.php`
const response = await apiFetch(url, {
method: isEdit ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
const result = await response.json()
if (result.success) {
alert.success(result.message || (isEdit ? 'Nabídka byla uložena' : 'Nabídka byla vytvořena'))
if (!isEdit && result.data?.id) {
clearDraft()
const newId = result.data.id
setTimeout(() => navigate(`/offers/${newId}`, { replace: true }), 300)
}
} else {
alert.error(result.error || 'Nepodařilo se uložit nabídku')
}
} catch {
alert.error('Chyba připojení')
} finally {
setSaving(false)
}
}
const buildPayload = () => ({
quotation: {
project_code: form.project_code,
customer_id: form.customer_id,
created_at: form.created_at,
valid_until: form.valid_until,
currency: form.currency,
language: form.language,
vat_rate: form.vat_rate,
apply_vat: form.apply_vat,
exchange_rate: form.exchange_rate || null,
exchange_rate_date: form.exchange_rate_date || null,
scope_title: form.scope_title,
scope_description: form.scope_description
},
items: items.map((item, i) => ({
...item,
position: i + 1
})),
sections: sections.map((s, i) => ({
...s,
position: i + 1
}))
})
return {
loading, saving, errors, setErrors,
form, updateForm, items, setItems, sections,
customers, itemTemplates, scopeTemplates,
orderInfo, offerStatus, setOfferStatus,
totals, draftSavedAtLabel, clearDraft,
selectCustomer, clearCustomer,
updateItem, addItem, removeItem, addItemFromTemplate,
addSection, removeSection, updateSection, moveSection,
loadScopeTemplate, handleSave
}
}

View File

@@ -1,26 +1,15 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import DOMPurify from 'dompurify'
import { useAlert } from '../context/AlertContext' import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import Forbidden from '../components/Forbidden' import Forbidden from '../components/Forbidden'
import { Link } from 'react-router-dom'
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import ConfirmModal from '../components/ConfirmModal' import ConfirmModal from '../components/ConfirmModal'
import AdminDatePicker from '../components/AdminDatePicker' import AdminDatePicker from '../components/AdminDatePicker'
import BulkAttendanceModal from '../components/BulkAttendanceModal' import BulkAttendanceModal from '../components/BulkAttendanceModal'
import ShiftFormModal from '../components/ShiftFormModal' import ShiftFormModal from '../components/ShiftFormModal'
import AttendanceShiftTable from '../components/AttendanceShiftTable'
import useModalLock from '../hooks/useModalLock' import useModalLock from '../hooks/useModalLock'
import apiFetch from '../utils/api' import useAttendanceAdmin from '../hooks/useAttendanceAdmin'
import { import { formatMinutes } from '../utils/attendanceHelpers'
formatDate, formatDatetime, formatTime,
calculateWorkMinutes, formatMinutes,
getLeaveTypeName, getLeaveTypeBadgeClass,
getDatePart, getTimePart,
calcProjectMinutesTotal, calcFormWorkMinutes,
formatTimeOrDatetimePrint, calculateWorkMinutesPrint
} from '../utils/attendanceHelpers'
const API_BASE = '/api/admin'
function getFundBarBackground(data) { function getFundBarBackground(data) {
if (data.overtime > 0) return 'linear-gradient(135deg, var(--warning), #d97706)' if (data.overtime > 0) return 'linear-gradient(135deg, var(--warning), #d97706)'
@@ -28,160 +17,29 @@ function getFundBarBackground(data) {
return 'var(--gradient)' return 'var(--gradient)'
} }
function formatBreak(record) {
if (record.break_start && record.break_end) {
return `${formatTime(record.break_start)} - ${formatTime(record.break_end)}`
}
if (record.break_start) {
return `${formatTime(record.break_start)} - ?`
}
return '—'
}
function renderProjectCell(record) {
if (record.project_logs && record.project_logs.length > 0) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.125rem' }}>
{record.project_logs.map((log, i) => {
let h, m, isActive = false
if (log.hours !== null && log.hours !== undefined) {
h = parseInt(log.hours) || 0
m = parseInt(log.minutes) || 0
} else {
isActive = !log.ended_at
const end = log.ended_at ? new Date(log.ended_at) : new Date()
const mins = Math.floor((end - new Date(log.started_at)) / 60000)
h = Math.floor(mins / 60)
m = mins % 60
}
return (
<span key={log.id || i} className="admin-badge" style={{ fontSize: '0.7rem', display: 'inline-block', background: isActive ? 'var(--accent-light)' : undefined }}>
{log.project_name || `#${log.project_id}`} ({h}:{String(m).padStart(2, '0')}h{isActive ? ' ▸' : ''})
</span>
)
})}
</div>
)
}
if (record.project_name) {
return <span className="admin-badge admin-badge-wrap" style={{ fontSize: '0.75rem' }}>{record.project_name}</span>
}
return '—'
}
function renderFundStatus(userData) {
if (userData.overtime > 0) {
return <span className="leave-badge badge-overtime">+{userData.overtime}h přesčas</span>
}
if (userData.missing > 0) {
return <span style={{ color: '#dc2626' }}>{userData.missing}h</span>
}
return <span style={{ color: '#16a34a' }}>splněno</span>
}
export default function AttendanceAdmin() { export default function AttendanceAdmin() {
const alert = useAlert() const alert = useAlert()
const { hasPermission } = useAuth() const { hasPermission } = useAuth()
const [loading, setLoading] = useState(true)
const [month, setMonth] = useState(() => {
const now = new Date()
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
})
const [filterUserId, setFilterUserId] = useState('')
const [data, setData] = useState({
records: [],
users: [],
user_totals: {},
leave_balances: {}
})
const [printData, setPrintData] = useState(null)
const printRef = useRef(null)
const [showBulkModal, setShowBulkModal] = useState(false) const {
const [bulkSubmitting, setBulkSubmitting] = useState(false) loading, month, setMonth,
const [bulkForm, setBulkForm] = useState({ filterUserId, setFilterUserId,
month: '', data, hasData,
user_ids: [], showBulkModal, setShowBulkModal,
arrival_time: '08:00', bulkSubmitting, bulkForm, setBulkForm,
departure_time: '16:30', showCreateModal, setShowCreateModal,
break_start_time: '12:00', createForm, setCreateForm,
break_end_time: '12:30' showEditModal, setShowEditModal,
}) editingRecord, editForm, setEditForm,
deleteConfirm, setDeleteConfirm,
const [showCreateModal, setShowCreateModal] = useState(false) projectList,
const today = new Date().toISOString().split('T')[0] createProjectLogs, setCreateProjectLogs,
const [createForm, setCreateForm] = useState({ editProjectLogs, setEditProjectLogs,
user_id: '', openCreateModal, handleCreateShiftDateChange, handleCreateSubmit,
shift_date: today, openBulkModal, toggleBulkUser, toggleAllBulkUsers, handleBulkSubmit,
leave_type: 'work', openEditModal, handleEditSubmit,
leave_hours: 8, handleDelete, handlePrint
arrival_date: today, } = useAttendanceAdmin({ alert })
arrival_time: '',
break_start_date: today,
break_start_time: '',
break_end_date: today,
break_end_time: '',
departure_date: today,
departure_time: '',
notes: ''
})
const [showEditModal, setShowEditModal] = useState(false)
const [editingRecord, setEditingRecord] = useState(null)
const [editForm, setEditForm] = useState({
shift_date: '',
leave_type: 'work',
leave_hours: 8,
arrival_date: '',
arrival_time: '',
break_start_date: '',
break_start_time: '',
break_end_date: '',
break_end_time: '',
departure_date: '',
departure_time: '',
notes: ''
})
const [deleteConfirm, setDeleteConfirm] = useState({ show: false, record: null })
const [projectList, setProjectList] = useState([])
const [createProjectLogs, setCreateProjectLogs] = useState([])
const [editProjectLogs, setEditProjectLogs] = useState([])
useEffect(() => {
const loadProjects = async () => {
try {
const response = await apiFetch(`${API_BASE}/attendance.php?action=projects`)
const result = await response.json()
if (result.success) setProjectList(result.data.projects || [])
} catch { /* silent */ }
}
loadProjects()
}, [])
const fetchData = useCallback(async (showLoading = true) => {
if (showLoading) setLoading(true)
try {
let url = `${API_BASE}/attendance.php?action=admin&month=${month}`
if (filterUserId) {
url += `&user_id=${filterUserId}`
}
const response = await apiFetch(url)
if (response.status === 401) return
const result = await response.json()
if (result.success) {
setData(result.data)
}
} catch {
alert.error('Nepodařilo se načíst data')
} finally {
if (showLoading) setLoading(false)
}
}, [month, filterUserId, alert])
useEffect(() => {
fetchData()
}, [fetchData])
useModalLock(showBulkModal) useModalLock(showBulkModal)
useModalLock(showEditModal) useModalLock(showEditModal)
@@ -189,388 +47,6 @@ export default function AttendanceAdmin() {
if (!hasPermission('attendance.admin')) return <Forbidden /> if (!hasPermission('attendance.admin')) return <Forbidden />
// --- Create modal ---
const openCreateModal = () => {
const todayDate = new Date().toISOString().split('T')[0]
setCreateForm({
user_id: '',
shift_date: todayDate,
leave_type: 'work',
leave_hours: 8,
arrival_date: todayDate,
arrival_time: '',
break_start_date: todayDate,
break_start_time: '',
break_end_date: todayDate,
break_end_time: '',
departure_date: todayDate,
departure_time: '',
notes: '',
project_id: ''
})
setCreateProjectLogs([])
setShowCreateModal(true)
}
const handleCreateShiftDateChange = (newDate) => {
setCreateForm({
...createForm,
shift_date: newDate,
arrival_date: newDate,
break_start_date: newDate,
break_end_date: newDate,
departure_date: newDate
})
}
const handleCreateSubmit = async () => {
if (!createForm.user_id || !createForm.shift_date) {
alert.error('Vyplňte zaměstnance a datum směny')
return
}
const filteredCreateLogs = createProjectLogs.filter(l => l.project_id)
if (filteredCreateLogs.length > 0 && createForm.leave_type === 'work') {
const totalWork = calcFormWorkMinutes(createForm)
const totalProject = calcProjectMinutesTotal(filteredCreateLogs)
if (totalWork > 0 && totalProject !== totalWork) {
const wH = Math.floor(totalWork / 60)
const wM = totalWork % 60
const pH = Math.floor(totalProject / 60)
const pM = totalProject % 60
alert.error(`Součet hodin projektů (${pH}h ${pM}m) neodpovídá odpracovanému času (${wH}h ${wM}m)`)
return
}
}
try {
const payload = { ...createForm }
if (filteredCreateLogs.length > 0 && createForm.leave_type === 'work') {
payload.project_logs = filteredCreateLogs
}
const response = await apiFetch(`${API_BASE}/attendance.php?action=create`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
const result = await response.json()
if (result.success) {
setShowCreateModal(false)
await fetchData(false)
await new Promise(resolve => setTimeout(resolve, 300))
alert.success(result.message)
} else {
alert.error(result.error)
}
} catch {
alert.error('Chyba připojení')
}
}
// --- Bulk modal ---
const openBulkModal = () => {
setBulkForm({
month: month,
user_ids: data.users.map(u => String(u.id)),
arrival_time: '08:00',
departure_time: '16:30',
break_start_time: '12:00',
break_end_time: '12:30'
})
setShowBulkModal(true)
}
const toggleBulkUser = (userId) => {
const id = String(userId)
setBulkForm(prev => ({
...prev,
user_ids: prev.user_ids.includes(id)
? prev.user_ids.filter(u => u !== id)
: [...prev.user_ids, id]
}))
}
const toggleAllBulkUsers = () => {
const allIds = data.users.map(u => String(u.id))
setBulkForm(prev => ({
...prev,
user_ids: prev.user_ids.length === allIds.length ? [] : allIds
}))
}
const handleBulkSubmit = async () => {
if (!bulkForm.month) {
alert.error('Vyberte měsíc')
return
}
if (bulkForm.user_ids.length === 0) {
alert.error('Vyberte alespoň jednoho zaměstnance')
return
}
setBulkSubmitting(true)
try {
const response = await apiFetch(`${API_BASE}/attendance.php?action=bulk_attendance`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(bulkForm)
})
const result = await response.json()
if (result.success) {
setShowBulkModal(false)
await fetchData(false)
await new Promise(resolve => setTimeout(resolve, 300))
alert.success(result.message)
} else {
alert.error(result.error)
}
} catch {
alert.error('Chyba připojení')
} finally {
setBulkSubmitting(false)
}
}
// --- Edit modal ---
const openEditModal = (record) => {
setEditingRecord(record)
setEditForm({
shift_date: record.shift_date,
leave_type: record.leave_type || 'work',
leave_hours: record.leave_hours || 8,
arrival_date: getDatePart(record.arrival_time) || record.shift_date,
arrival_time: getTimePart(record.arrival_time),
break_start_date: getDatePart(record.break_start) || record.shift_date,
break_start_time: getTimePart(record.break_start),
break_end_date: getDatePart(record.break_end) || record.shift_date,
break_end_time: getTimePart(record.break_end),
departure_date: getDatePart(record.departure_time) || record.shift_date,
departure_time: getTimePart(record.departure_time),
notes: record.notes || '',
project_id: record.project_id || ''
})
const logs = (record.project_logs || []).map(l => {
if (l.hours !== null && l.hours !== undefined) {
return {
project_id: String(l.project_id),
hours: String(l.hours),
minutes: String(l.minutes || 0)
}
}
if (l.started_at && l.ended_at) {
const mins = Math.max(0, Math.floor((new Date(l.ended_at) - new Date(l.started_at)) / 60000))
return {
project_id: String(l.project_id),
hours: String(Math.floor(mins / 60)),
minutes: String(mins % 60)
}
}
return { project_id: String(l.project_id), hours: '', minutes: '' }
})
setEditProjectLogs(logs)
setShowEditModal(true)
}
const handleEditSubmit = async () => {
const isWork = (editForm.leave_type || 'work') === 'work'
const filteredEditLogs = isWork ? editProjectLogs.filter(l => l.project_id) : []
if (filteredEditLogs.length > 0) {
const totalWork = calcFormWorkMinutes(editForm)
const totalProject = calcProjectMinutesTotal(filteredEditLogs)
if (totalWork > 0 && totalProject !== totalWork) {
const wH = Math.floor(totalWork / 60)
const wM = totalWork % 60
const pH = Math.floor(totalProject / 60)
const pM = totalProject % 60
alert.error(`Součet hodin projektů (${pH}h ${pM}m) neodpovídá odpracovanému času (${wH}h ${wM}m)`)
return
}
}
try {
const payload = { ...editForm }
payload.project_logs = filteredEditLogs
const response = await apiFetch(`${API_BASE}/attendance.php?id=${editingRecord.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
const result = await response.json()
if (result.success) {
setShowEditModal(false)
await fetchData(false)
await new Promise(resolve => setTimeout(resolve, 300))
alert.success(result.message)
} else {
alert.error(result.error)
}
} catch {
alert.error('Chyba připojení')
}
}
// --- Delete ---
const handleDelete = async () => {
if (!deleteConfirm.record) return
try {
const response = await apiFetch(`${API_BASE}/attendance.php?id=${deleteConfirm.record.id}`, {
method: 'DELETE',
credentials: 'include'
})
const result = await response.json()
if (result.success) {
setDeleteConfirm({ show: false, record: null })
await fetchData(false)
alert.success(result.message)
} else {
alert.error(result.error)
}
} catch {
alert.error('Chyba připojení')
}
}
// --- Print ---
const handlePrint = async () => {
try {
let url = `${API_BASE}/attendance.php?action=print&month=${month}`
if (filterUserId) {
url += `&user_id=${filterUserId}`
}
const response = await apiFetch(url)
if (response.status === 401) return
const result = await response.json()
if (result.success) {
setPrintData(result.data)
setTimeout(() => {
if (printRef.current) {
const printWindow = window.open('', '_blank')
printWindow.document.write(`
<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Docházka - ${result.data.month_name}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
font-size: 11px;
line-height: 1.4;
color: #000;
background: #fff;
padding: 15mm;
}
.print-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 2px solid #333;
}
.print-header-left {
display: flex;
align-items: center;
gap: 12px;
}
.print-logo {
height: 40px;
width: auto;
}
.print-header-text { text-align: left; }
.print-header-right { text-align: right; }
.print-header h1 { font-size: 18px; font-weight: 700; margin-bottom: 3px; }
.print-header .company { font-size: 11px; color: #666; }
.print-header .period { font-size: 13px; font-weight: 600; color: #333; margin-bottom: 2px; }
.print-header .filters { font-size: 10px; color: #666; }
.print-header .generated { font-size: 9px; color: #888; margin-top: 5px; }
.user-section { margin-bottom: 25px; page-break-inside: avoid; }
.user-header {
background: #f5f5f5;
border: 1px solid #ddd;
padding: 10px 15px;
margin-bottom: 10px;
display: flex;
justify-content: space-between;
align-items: center;
}
.user-header h3 { font-size: 13px; font-weight: 600; }
.user-header .total { font-size: 12px; font-weight: 600; }
.user-section table { width: 100%; border-collapse: collapse; margin-bottom: 15px; }
.user-section th, .user-section td { border: 1px solid #333; padding: 6px 8px; text-align: left; }
.user-section th { background: #333; color: #fff; font-weight: 600; font-size: 10px; text-transform: uppercase; }
.user-section td { font-size: 10px; }
.user-section tr:nth-child(even) { background: #f9f9f9; }
.text-center { text-align: center; }
.text-right { text-align: right; }
.user-section tfoot td { background: #eee; font-weight: 600; }
.leave-badge {
display: inline-block;
padding: 2px 6px;
border-radius: 3px;
font-size: 9px;
font-weight: 500;
}
.badge-vacation { background: #dbeafe; color: #1d4ed8; }
.badge-sick { background: #fee2e2; color: #dc2626; }
.badge-holiday { background: #dcfce7; color: #16a34a; }
.badge-unpaid { background: #f3f4f6; color: #6b7280; }
.badge-overtime { background: #fef3c7; color: #d97706; }
.leave-summary {
margin-top: 10px;
padding: 8px 15px;
background: #f9f9f9;
border: 1px solid #ddd;
font-size: 10px;
}
.print-wrapper-table {
width: 100%;
border-collapse: collapse;
border: none;
}
.print-wrapper-table > thead > tr > td,
.print-wrapper-table > tbody > tr > td {
padding: 0;
border: none;
background: none;
}
@media print {
body { padding: 0; margin: 0; }
@page { size: A4 portrait; margin: 10mm; }
.user-section { page-break-inside: avoid; }
}
</style>
</head>
<body>
${DOMPurify.sanitize(printRef.current.innerHTML)}
</body>
</html>
`)
printWindow.document.close()
printWindow.onload = () => {
printWindow.print()
}
}
}, 100)
}
} catch {
alert.error('Nepodařilo se připravit tisk')
}
}
const hasData = Object.keys(data.user_totals).length > 0
return ( return (
<div> <div>
<motion.div <motion.div
@@ -761,98 +237,12 @@ export default function AttendanceAdmin() {
))} ))}
</div> </div>
)} )}
{!loading && data.records.length === 0 && ( {!loading && (
<div className="admin-empty-state"> <AttendanceShiftTable
<p>Za tento měsíc nejsou žádné záznamy.</p> records={data.records}
</div> onEdit={openEditModal}
)} onDelete={(record) => setDeleteConfirm({ show: true, record })}
{!loading && data.records.length > 0 && ( />
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Datum</th>
<th>Zaměstnanec</th>
<th>Typ</th>
<th>Příchod</th>
<th>Pauza</th>
<th>Odchod</th>
<th>Hodiny</th>
<th>Projekt</th>
<th>GPS</th>
<th>Poznámka</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{data.records.map((record) => {
const leaveType = record.leave_type || 'work'
const isLeave = leaveType !== 'work'
const workMinutes = isLeave
? (record.leave_hours || 8) * 60
: calculateWorkMinutes(record)
const hasLocation = (record.arrival_lat && record.arrival_lng) || (record.departure_lat && record.departure_lng)
return (
<tr key={record.id}>
<td className="admin-mono">{formatDate(record.shift_date)}</td>
<td>{record.user_name}</td>
<td>
<span className={`attendance-leave-badge ${getLeaveTypeBadgeClass(leaveType)}`}>
{getLeaveTypeName(leaveType)}
</span>
</td>
<td className="admin-mono">{isLeave ? '—' : formatDatetime(record.arrival_time)}</td>
<td className="admin-mono">
{isLeave ? '—' : formatBreak(record)}
</td>
<td className="admin-mono">{isLeave ? '—' : formatDatetime(record.departure_time)}</td>
<td className="admin-mono">{workMinutes > 0 ? `${formatMinutes(workMinutes)} h` : '—'}</td>
<td>
{renderProjectCell(record)}
</td>
<td>
{hasLocation ? (
<Link to={`/attendance/location/${record.id}`} className="attendance-gps-link" title="Zobrazit polohu" aria-label="Zobrazit polohu">
📍
</Link>
) : '—'}
</td>
<td style={{ maxWidth: '100px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={record.notes || ''}>
{record.notes || ''}
</td>
<td>
<div className="admin-table-actions">
<button
onClick={() => openEditModal(record)}
className="admin-btn-icon"
title="Upravit"
aria-label="Upravit"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
<button
onClick={() => setDeleteConfirm({ show: true, record })}
className="admin-btn-icon danger"
title="Smazat"
aria-label="Smazat"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)} )}
</div> </div>
</motion.div> </motion.div>
@@ -906,131 +296,6 @@ export default function AttendanceAdmin() {
confirmText="Smazat" confirmText="Smazat"
confirmVariant="danger" confirmVariant="danger"
/> />
{/* Hidden Print Content */}
{printData && (
<div ref={printRef} style={{ display: 'none' }}>
<table className="print-wrapper-table">
<thead>
<tr><td>
<div className="print-header">
<div className="print-header-left">
<img src="/images/logo-light.png" alt="BOHA" className="print-logo" />
<div className="print-header-text">
<h1>EVIDENCE DOCHÁZKY</h1>
<div className="company">BOHA Automation s.r.o.</div>
</div>
</div>
<div className="print-header-right">
<div className="period">{printData.month_name}</div>
{printData.selected_user_name && <div className="filters">Zaměstnanec: {printData.selected_user_name}</div>}
<div className="generated">Vygenerováno: {new Date().toLocaleString('cs-CZ')}</div>
</div>
</div>
</td></tr>
</thead>
<tbody>
<tr><td>
{Object.entries(printData.user_totals).map(([userId, userData]) => (
<div key={userId} className="user-section">
<div className="user-header">
<h3>{userData.name}</h3>
<span className="total">Odpracováno: {formatMinutes(userData.minutes)} h</span>
</div>
{printData.leave_balances[userId] && (
<div className="leave-summary">
<strong>Dovolená {printData.year}:</strong> Zbývá {printData.leave_balances[userId].vacation_remaining.toFixed(1)}h z {printData.leave_balances[userId].vacation_total}h
{userData.vacation_hours > 0 && <> | <span className="leave-badge badge-vacation">Tento měsíc: {userData.vacation_hours}h</span></>}
{userData.sick_hours > 0 && <> | <span className="leave-badge badge-sick">Nemoc: {userData.sick_hours}h</span></>}
{userData.holiday_hours > 0 && <> | <span className="leave-badge badge-holiday">Svátek: {userData.holiday_hours}h</span></>}
{userData.overtime > 0 && <> | <span className="leave-badge badge-overtime">Přesčas: +{userData.overtime}h</span></>}
</div>
)}
<table>
<thead>
<tr>
<th style={{ width: '70px' }}>Datum</th>
<th style={{ width: '70px' }}>Typ</th>
<th className="text-center" style={{ width: '70px' }}>Příchod</th>
<th className="text-center" style={{ width: '90px' }}>Pauza</th>
<th className="text-center" style={{ width: '70px' }}>Odchod</th>
<th className="text-center" style={{ width: '80px' }}>Hodiny</th>
<th>Projekty</th>
<th>Poznámka</th>
</tr>
</thead>
<tbody>
{userData.records.map((record) => {
const leaveType = record.leave_type || 'work'
const isLeave = leaveType !== 'work'
const workMinutes = calculateWorkMinutesPrint(record)
const hours = Math.floor(workMinutes / 60)
const mins = workMinutes % 60
return (
<tr key={record.id}>
<td>{formatDate(record.shift_date)}</td>
<td><span className={`leave-badge ${getLeaveTypeBadgeClass(leaveType)}`}>{getLeaveTypeName(leaveType)}</span></td>
<td className="text-center">{isLeave ? '—' : formatTimeOrDatetimePrint(record.arrival_time, record.shift_date)}</td>
<td className="text-center">
{isLeave || !record.break_start || !record.break_end
? '—'
: `${formatTimeOrDatetimePrint(record.break_start, record.shift_date)} - ${formatTimeOrDatetimePrint(record.break_end, record.shift_date)}`
}
</td>
<td className="text-center">{isLeave ? '—' : formatTimeOrDatetimePrint(record.departure_time, record.shift_date)}</td>
<td className="text-center">{workMinutes > 0 ? `${hours}:${String(mins).padStart(2, '0')}` : '—'}</td>
<td style={{ fontSize: '8px' }}>
{(record.project_logs && record.project_logs.length > 0)
? record.project_logs.map((log, i) => {
let h, m
if (log.hours !== null && log.hours !== undefined) {
h = parseInt(log.hours) || 0; m = parseInt(log.minutes) || 0
} else if (log.started_at && log.ended_at) {
const mins2 = Math.max(0, Math.floor((new Date(log.ended_at) - new Date(log.started_at)) / 60000))
h = Math.floor(mins2 / 60); m = mins2 % 60
} else { h = 0; m = 0 }
return <div key={log.id || i}>{log.project_name || `#${log.project_id}`} ({h}:{String(m).padStart(2, '0')}h)</div>
})
: record.project_name || '—'}
</td>
<td>{record.notes || ''}</td>
</tr>
)
})}
</tbody>
<tfoot>
<tr>
<td colSpan={6} className="text-right">Odpracováno:</td>
<td className="text-center">{formatMinutes(userData.minutes)} h</td>
<td colSpan={2}></td>
</tr>
{userData.fund !== null && (
<tr>
<td colSpan={6} className="text-right">Fond měsíce:</td>
<td className="text-center">{userData.covered}h / {userData.fund}h</td>
<td colSpan={2}>
{renderFundStatus(userData)}
</td>
</tr>
)}
</tfoot>
</table>
</div>
))}
{Object.keys(printData.user_totals).length === 0 && (
<p style={{ textAlign: 'center', padding: '20px' }}>Za vybrané období nejsou žádné záznamy.</p>
)}
</td></tr>
</tbody>
</table>
</div>
)}
</div> </div>
) )
} }

View File

@@ -0,0 +1,273 @@
import { useState, useEffect, useCallback } from 'react'
import { motion } from 'framer-motion'
import { useAuth } from '../context/AuthContext'
import { useAlert } from '../context/AlertContext'
import Forbidden from '../components/Forbidden'
import Pagination from '../components/Pagination'
import apiFetch from '../utils/api'
const API_BASE = '/api/admin'
const ACTION_LABELS = {
create: 'Vytvoření',
update: 'Úprava',
delete: 'Smazání',
login: 'Přihlášení',
login_failed: 'Neúspěšné přihlášení',
logout: 'Odhlášení',
view: 'Zobrazení',
activate: 'Aktivace',
deactivate: 'Deaktivace',
password_change: 'Změna hesla',
permission_change: 'Změna oprávnění',
access_denied: 'Přístup odepřen',
}
const ACTION_BADGE_CLASS = {
create: 'admin-badge-success',
update: 'admin-badge-info',
delete: 'admin-badge-warning',
login: 'admin-badge-secondary',
login_failed: 'admin-badge-warning',
logout: 'admin-badge-secondary',
view: 'admin-badge-info',
activate: 'admin-badge-success',
deactivate: 'admin-badge-warning',
password_change: 'admin-badge-info',
permission_change: 'admin-badge-info',
access_denied: 'admin-badge-warning',
}
const ENTITY_TYPE_LABELS = {
user: 'Uživatel',
attendance: 'Docházka',
leave_request: 'Žádost o nepřítomnost',
offers_quotation: 'Nabídka',
offers_customer: 'Zákazník',
offers_item_template: 'Šablona položky',
offers_scope_template: 'Šablona rozsahu',
offers_settings: 'Nastavení nabídek',
orders_order: 'Objednávka',
invoices_invoice: 'Faktura',
projects_project: 'Projekt',
role: 'Role',
trips: 'Jízda',
vehicles: 'Vozidlo',
bank_account: 'Bankovní účet',
}
const ACTION_OPTIONS = Object.entries(ACTION_LABELS).map(([value, label]) => ({ value, label }))
const ENTITY_OPTIONS = Object.entries(ENTITY_TYPE_LABELS).map(([value, label]) => ({ value, label }))
export default function AuditLog() {
const { hasPermission } = useAuth()
const alert = useAlert()
const [logs, setLogs] = useState([])
const [loading, setLoading] = useState(true)
const [pagination, setPagination] = useState(null)
const [filters, setFilters] = useState({
search: '',
action: '',
entity_type: '',
date_from: '',
date_to: '',
})
const fetchLogs = useCallback(async (page = 1, perPage = 50) => {
setLoading(true)
try {
const params = new URLSearchParams({ page: String(page), per_page: String(perPage) })
if (filters.search) {
params.set('search', filters.search)
}
if (filters.action) {
params.set('action', filters.action)
}
if (filters.entity_type) {
params.set('entity_type', filters.entity_type)
}
if (filters.date_from) {
params.set('date_from', filters.date_from)
}
if (filters.date_to) {
params.set('date_to', filters.date_to)
}
const response = await apiFetch(`${API_BASE}/audit-log.php?${params.toString()}`)
const data = await response.json()
if (data.success) {
setLogs(data.data.logs || [])
setPagination({
total: data.data.total,
page: data.data.page,
per_page: data.data.per_page,
total_pages: data.data.pages,
})
} else {
alert.error(data.error || 'Nepodařilo se načíst audit log')
}
} catch {
alert.error('Chyba připojení')
} finally {
setLoading(false)
}
}, [filters]) // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
fetchLogs()
}, [fetchLogs])
if (!hasPermission('settings.audit')) {
return <Forbidden />
}
const handleFilterChange = (key, value) => {
setFilters(prev => ({ ...prev, [key]: value }))
}
const handlePageChange = (newPage) => {
fetchLogs(newPage, pagination?.per_page || 50)
}
const handlePerPageChange = (newPerPage) => {
fetchLogs(1, newPerPage)
}
const formatDatetime = (dateString) => {
if (!dateString) {
return '-'
}
return new Date(dateString).toLocaleString('cs-CZ')
}
const renderSkeletonRows = () => (
Array.from({ length: 10 }, (_, i) => (
<tr key={`skeleton-${i}`}>
{Array.from({ length: 6 }, (_, j) => (
<td key={j}><div className="admin-skeleton" style={{ height: '16px', width: `${60 + Math.random() * 40}%` }} /></td>
))}
</tr>
))
)
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
<div className="admin-page-header">
<h1>Audit log</h1>
</div>
<div className="admin-card" style={{ marginBottom: '1rem' }}>
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', alignItems: 'flex-end' }}>
<div style={{ flex: '1 1 200px', minWidth: '150px' }}>
<label className="admin-form-label">Hledat</label>
<input
type="text"
className="admin-form-input"
placeholder="Popis, uživatel..."
value={filters.search}
onChange={(e) => handleFilterChange('search', e.target.value)}
/>
</div>
<div style={{ flex: '0 1 180px', minWidth: '140px' }}>
<label className="admin-form-label">Akce</label>
<select
className="admin-form-input"
value={filters.action}
onChange={(e) => handleFilterChange('action', e.target.value)}
>
<option value="">Všechny akce</option>
{ACTION_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
<div style={{ flex: '0 1 180px', minWidth: '140px' }}>
<label className="admin-form-label">Typ entity</label>
<select
className="admin-form-input"
value={filters.entity_type}
onChange={(e) => handleFilterChange('entity_type', e.target.value)}
>
<option value="">Všechny typy</option>
{ENTITY_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
<div style={{ flex: '0 1 160px', minWidth: '130px' }}>
<label className="admin-form-label">Od</label>
<input
type="date"
className="admin-form-input"
value={filters.date_from}
onChange={(e) => handleFilterChange('date_from', e.target.value)}
/>
</div>
<div style={{ flex: '0 1 160px', minWidth: '130px' }}>
<label className="admin-form-label">Do</label>
<input
type="date"
className="admin-form-input"
value={filters.date_to}
onChange={(e) => handleFilterChange('date_to', e.target.value)}
/>
</div>
</div>
</div>
<div className="admin-card">
<div className="admin-table-wrapper">
<table className="admin-table">
<thead>
<tr>
<th>Čas</th>
<th>Uživatel</th>
<th>Akce</th>
<th>Typ entity</th>
<th>Popis</th>
<th>IP</th>
</tr>
</thead>
<tbody>
{loading && renderSkeletonRows()}
{!loading && logs.length === 0 && (
<tr>
<td colSpan="6" style={{ textAlign: 'center', padding: '2rem' }}>
Žádné záznamy k zobrazení
</td>
</tr>
)}
{!loading && logs.map((log) => (
<tr key={log.id}>
<td className="admin-mono">{formatDatetime(log.created_at)}</td>
<td style={{ fontWeight: 500 }}>{log.username || '-'}</td>
<td>
<span className={`admin-badge ${ACTION_BADGE_CLASS[log.action] || 'admin-badge-secondary'}`}>
{ACTION_LABELS[log.action] || log.action}
</span>
</td>
<td>{ENTITY_TYPE_LABELS[log.entity_type] || log.entity_type || '-'}</td>
<td>{log.description || '-'}</td>
<td className="admin-mono">{log.user_ip || '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
<Pagination
pagination={pagination}
onPageChange={handlePageChange}
onPerPageChange={handlePerPageChange}
/>
</div>
</motion.div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,7 @@ import { formatCurrency, formatDate, czechPlural } from '../utils/formatters'
import SortIcon from '../components/SortIcon' import SortIcon from '../components/SortIcon'
import useTableSort from '../hooks/useTableSort' import useTableSort from '../hooks/useTableSort'
import useListData from '../hooks/useListData' import useListData from '../hooks/useListData'
import Pagination from '../components/Pagination'
const ReceivedInvoices = lazy(() => import('./ReceivedInvoices')) const ReceivedInvoices = lazy(() => import('./ReceivedInvoices'))
const API_BASE = '/api/admin' const API_BASE = '/api/admin'
@@ -65,6 +66,7 @@ export default function Invoices() {
const [receivedUploadOpen, setReceivedUploadOpen] = useState(false) const [receivedUploadOpen, setReceivedUploadOpen] = useState(false)
const { sort, order, handleSort, activeSort } = useTableSort('invoice_number') const { sort, order, handleSort, activeSort } = useTableSort('invoice_number')
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [page, setPage] = useState(1)
const [statusFilter, setStatusFilter] = useState('') const [statusFilter, setStatusFilter] = useState('')
const now = new Date() const now = new Date()
@@ -139,8 +141,8 @@ export default function Invoices() {
setDraft(null) setDraft(null)
} }
const { items: invoices, loading, refetch: fetchData } = useListData('invoices.php', { const { items: invoices, loading, pagination, refetch: fetchData } = useListData('invoices.php', {
dataKey: 'invoices', search, sort, order, dataKey: 'invoices', search, sort, order, page,
extraParams: statusFilter ? { status: statusFilter } : {}, extraParams: statusFilter ? { status: statusFilter } : {},
errorMsg: 'Nepodařilo se načíst faktury' errorMsg: 'Nepodařilo se načíst faktury'
}) })
@@ -269,7 +271,7 @@ export default function Invoices() {
<div> <div>
<h1 className="admin-page-title">Faktury</h1> <h1 className="admin-page-title">Faktury</h1>
<p className="admin-page-subtitle"> <p className="admin-page-subtitle">
{invoices.length} {czechPlural(invoices.length, 'faktura', 'faktury', 'faktur')} {pagination?.total ?? invoices.length} {czechPlural(pagination?.total ?? invoices.length, 'faktura', 'faktury', 'faktur')}
</p> </p>
</div> </div>
{hasPermission('invoices.create') && ( {hasPermission('invoices.create') && (
@@ -430,7 +432,7 @@ export default function Invoices() {
<button <button
key={f.value} key={f.value}
className={`offers-tab ${statusFilter === f.value ? 'active' : ''}`} className={`offers-tab ${statusFilter === f.value ? 'active' : ''}`}
onClick={() => setStatusFilter(f.value)} onClick={() => { setStatusFilter(f.value); setPage(1) }}
> >
{f.label} {f.label}
</button> </button>
@@ -450,7 +452,7 @@ export default function Invoices() {
<input <input
type="text" type="text"
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => { setSearch(e.target.value); setPage(1) }}
className="admin-form-input" className="admin-form-input"
placeholder="Hledat podle čísla faktury, zákazníka nebo IČ..." placeholder="Hledat podle čísla faktury, zákazníka nebo IČ..."
/> />
@@ -622,6 +624,7 @@ export default function Invoices() {
</table> </table>
</div> </div>
)} )}
<Pagination pagination={pagination} onPageChange={setPage} />
</div> </div>
</motion.div> </motion.div>

View File

@@ -0,0 +1,30 @@
import { Link } from 'react-router-dom'
import { motion } from 'framer-motion'
export default function NotFound() {
return (
<motion.div
className="admin-empty-state"
style={{ minHeight: '60vh', justifyContent: 'center' }}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
<div className="admin-empty-icon" style={{ width: 80, height: 80, marginBottom: '1.5rem' }}>
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<path d="M16 16s-1.5-2-4-2-4 2-4 2" />
<line x1="9" y1="9" x2="9.01" y2="9" />
<line x1="15" y1="9" x2="15.01" y2="9" />
</svg>
</div>
<h2 style={{ fontSize: '1.5rem', fontWeight: 600, marginBottom: '0.5rem', color: 'var(--text-primary)' }}>
404
</h2>
<p>Stránka nebyla nalezena.</p>
<Link to="/" className="admin-btn admin-btn-primary" style={{ marginTop: '0.5rem' }}>
Zpět na Dashboard
</Link>
</motion.div>
)
}

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react' import { useState } from 'react'
import { useAlert } from '../context/AlertContext' import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import { useParams, useNavigate, Link } from 'react-router-dom' import { useParams, useNavigate, Link } from 'react-router-dom'
@@ -9,28 +9,12 @@ import Forbidden from '../components/Forbidden'
import AdminDatePicker from '../components/AdminDatePicker' import AdminDatePicker from '../components/AdminDatePicker'
import OfferItemsSection from '../components/OfferItemsSection' import OfferItemsSection from '../components/OfferItemsSection'
import OfferScopeSection from '../components/OfferScopeSection' import OfferScopeSection from '../components/OfferScopeSection'
import OfferCustomerPicker from '../components/OfferCustomerPicker'
import useModalLock from '../hooks/useModalLock' import useModalLock from '../hooks/useModalLock'
import useOfferForm from '../hooks/useOfferForm'
import apiFetch from '../utils/api' import apiFetch from '../utils/api'
const API_BASE = '/api/admin' const API_BASE = '/api/admin'
let _keyCounter = 0
const emptyItem = () => ({
_key: `item-${++_keyCounter}`,
description: '',
item_description: '',
quantity: 1,
unit: '',
unit_price: 0,
is_included_in_total: true
})
const emptySection = () => ({
_key: `sec-${++_keyCounter}`,
title: '',
title_cz: '',
content: ''
})
export default function OfferDetail() { export default function OfferDetail() {
const { id } = useParams() const { id } = useParams()
const isEdit = Boolean(id) const isEdit = Boolean(id)
@@ -38,361 +22,35 @@ export default function OfferDetail() {
const { hasPermission } = useAuth() const { hasPermission } = useAuth()
const navigate = useNavigate() const navigate = useNavigate()
const [loading, setLoading] = useState(isEdit) const {
const [saving, setSaving] = useState(false) loading, saving, errors, setErrors,
const [errors, setErrors] = useState({}) form, updateForm, items, setItems, sections,
const [customers, setCustomers] = useState([]) customers, itemTemplates, scopeTemplates,
const [customerSearch, setCustomerSearch] = useState('') orderInfo, offerStatus, setOfferStatus,
const [showCustomerDropdown, setShowCustomerDropdown] = useState(false) totals, draftSavedAtLabel,
const [itemTemplates, setItemTemplates] = useState([]) selectCustomer, clearCustomer,
const [scopeTemplates, setScopeTemplates] = useState([]) updateItem, addItem, removeItem, addItemFromTemplate,
addSection, removeSection, updateSection, moveSection,
loadScopeTemplate, handleSave
} = useOfferForm({ id, isEdit, alert, navigate })
const [showItemTemplateMenu, setShowItemTemplateMenu] = useState(false) const [showItemTemplateMenu, setShowItemTemplateMenu] = useState(false)
const [showScopeTemplateMenu, setShowScopeTemplateMenu] = useState(false) const [showScopeTemplateMenu, setShowScopeTemplateMenu] = useState(false)
const [form, setForm] = useState({
quotation_number: '',
project_code: '',
customer_id: null,
customer_name: '',
created_at: new Date().toISOString().split('T')[0],
valid_until: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
currency: 'EUR',
language: 'EN',
vat_rate: 21,
apply_vat: false,
exchange_rate: '',
exchange_rate_date: '',
scope_title: '',
scope_description: ''
})
const [items, setItems] = useState([emptyItem()])
const [sections, setSections] = useState([])
const [deleteConfirm, setDeleteConfirm] = useState(false) const [deleteConfirm, setDeleteConfirm] = useState(false)
const [deleting, setDeleting] = useState(false) const [deleting, setDeleting] = useState(false)
const [orderInfo, setOrderInfo] = useState(null)
const [creatingOrder, setCreatingOrder] = useState(false) const [creatingOrder, setCreatingOrder] = useState(false)
const [showOrderModal, setShowOrderModal] = useState(false) const [showOrderModal, setShowOrderModal] = useState(false)
const [offerStatus, setOfferStatus] = useState('active')
const [invalidateConfirm, setInvalidateConfirm] = useState(false) const [invalidateConfirm, setInvalidateConfirm] = useState(false)
const [invalidatingOffer, setInvalidatingOffer] = useState(false) const [invalidatingOffer, setInvalidatingOffer] = useState(false)
useModalLock(showOrderModal)
const [customerOrderNumber, setCustomerOrderNumber] = useState('') const [customerOrderNumber, setCustomerOrderNumber] = useState('')
const [orderAttachment, setOrderAttachment] = useState(null) const [orderAttachment, setOrderAttachment] = useState(null)
const [pdfLoading, setPdfLoading] = useState(false)
const DRAFT_KEY = 'boha_offer_draft' useModalLock(showOrderModal)
const [draftSavedAt, setDraftSavedAt] = useState(null)
const draftDataRef = useRef({ form, items, sections })
const draftRestoredRef = useRef(false)
// Fetch customers + templates on mount const isInvalidated = offerStatus === 'invalidated'
useEffect(() => { const isExpiredNotInvalidated = isEdit && !isInvalidated && !orderInfo && form.valid_until && new Date(form.valid_until) < new Date(new Date().toDateString())
const fetchMeta = async () => {
try {
const [custRes, itemTplRes, scopeTplRes] = await Promise.all([
apiFetch(`${API_BASE}/customers.php`),
apiFetch(`${API_BASE}/offers-templates.php?action=items`),
apiFetch(`${API_BASE}/offers-templates.php?action=scopes`)
])
const custData = await custRes.json()
const itemTplData = await itemTplRes.json()
const scopeTplData = await scopeTplRes.json()
if (custData.success) setCustomers(custData.data.customers)
if (itemTplData.success) setItemTemplates(itemTplData.data.templates)
if (scopeTplData.success) setScopeTemplates(scopeTplData.data.templates)
} catch { /* silent */ }
}
fetchMeta()
}, [])
// Restore draft on mount (new offers only)
useEffect(() => {
if (isEdit) return
try {
const raw = localStorage.getItem(DRAFT_KEY)
if (!raw) return
const draft = JSON.parse(raw)
if (!draft || typeof draft !== 'object' || !draft.form || !Array.isArray(draft.items)) {
localStorage.removeItem(DRAFT_KEY)
return
}
const { form: dForm, items: dItems, sections: dSections, savedAt } = draft
setForm(prev => ({
...prev,
project_code: dForm.project_code ?? prev.project_code,
customer_id: dForm.customer_id ?? prev.customer_id,
customer_name: dForm.customer_name ?? prev.customer_name,
created_at: dForm.created_at ?? prev.created_at,
valid_until: dForm.valid_until ?? prev.valid_until,
currency: dForm.currency ?? prev.currency,
language: dForm.language ?? prev.language,
vat_rate: dForm.vat_rate ?? prev.vat_rate,
apply_vat: dForm.apply_vat ?? prev.apply_vat,
exchange_rate: dForm.exchange_rate ?? prev.exchange_rate,
exchange_rate_date: dForm.exchange_rate_date ?? prev.exchange_rate_date,
scope_title: dForm.scope_title ?? prev.scope_title,
scope_description: dForm.scope_description ?? prev.scope_description,
}))
if (dItems.length) setItems(dItems.map(i => ({ ...i, _key: i._key || `item-${++_keyCounter}` })))
if (Array.isArray(dSections) && dSections.length) setSections(dSections.map(s => ({ ...s, _key: s._key || `sec-${++_keyCounter}` })))
draftRestoredRef.current = true
if (savedAt) setDraftSavedAt(new Date(savedAt))
} catch {
try { localStorage.removeItem(DRAFT_KEY) } catch { /* ignore */ }
}
}, [isEdit])
useEffect(() => {
draftDataRef.current = { form, items, sections }
}, [form, items, sections])
// Auto-save draft (jen nove nabidky)
useEffect(() => {
if (isEdit) return
const timer = setTimeout(() => {
try {
const { form: f, items: it, sections: sc } = draftDataRef.current
const { quotation_number: _qn, ...formWithoutNumber } = f
const savedAt = new Date().toISOString()
localStorage.setItem(DRAFT_KEY, JSON.stringify({ form: formWithoutNumber, items: it, sections: sc, savedAt }))
setDraftSavedAt(new Date(savedAt))
} catch { /* ignore */ }
}, 500)
return () => clearTimeout(timer)
}, [form, items, sections, isEdit])
useEffect(() => {
if (!isEdit) {
const fetchNextNumber = async () => {
try {
const response = await apiFetch(`${API_BASE}/offers.php?action=next_number`)
const result = await response.json()
if (result.success) {
setForm(prev => ({ ...prev, quotation_number: result.data.number }))
}
} catch { /* ignore */ }
}
// Fetch default settings
const fetchDefaults = async () => {
try {
const response = await apiFetch(`${API_BASE}/company-settings.php`)
const result = await response.json()
if (result.success && !draftRestoredRef.current) {
const s = result.data
setForm(prev => ({
...prev,
currency: s.default_currency || prev.currency,
vat_rate: s.default_vat_rate || prev.vat_rate
}))
}
} catch { /* ignore */ }
}
fetchNextNumber()
fetchDefaults()
return
}
const fetchDetail = async () => {
try {
const response = await apiFetch(`${API_BASE}/offers.php?action=detail&id=${id}`)
if (response.status === 401) return
const result = await response.json()
if (result.success) {
const q = result.data
setForm({
quotation_number: q.quotation_number || '',
project_code: q.project_code || '',
customer_id: q.customer_id || null,
customer_name: q.customer_name || '',
created_at: (q.created_at || '').substring(0, 10),
valid_until: (q.valid_until || '').substring(0, 10),
currency: q.currency || 'EUR',
language: q.language || 'EN',
vat_rate: q.vat_rate || 21,
apply_vat: Boolean(q.apply_vat),
exchange_rate: q.exchange_rate || '',
exchange_rate_date: q.exchange_rate_date || '',
scope_title: q.scope_title || '',
scope_description: q.scope_description || ''
})
if (q.items?.length) {
setItems(q.items.map(item => ({
_key: `item-${++_keyCounter}`,
description: item.description || '',
item_description: item.item_description || '',
quantity: Number(item.quantity) || 1,
unit: item.unit || '',
unit_price: Number(item.unit_price) || 0,
is_included_in_total: Boolean(item.is_included_in_total)
})))
}
if (q.sections?.length) {
setSections(q.sections.map(s => ({
_key: `sec-${++_keyCounter}`,
title: s.title || '',
title_cz: s.title_cz || '',
content: s.content || ''
})))
}
setOrderInfo(q.order || null)
setOfferStatus(q.status || 'active')
} else {
alert.error(result.error || 'Nepodařilo se načíst nabídku')
navigate('/offers')
}
} catch {
alert.error('Chyba připojení')
navigate('/offers')
} finally {
setLoading(false)
}
}
fetchDetail()
}, [isEdit, id, alert, navigate])
// Close customer dropdown on outside click
useEffect(() => {
const handleClickOutside = () => setShowCustomerDropdown(false)
if (showCustomerDropdown) {
document.addEventListener('click', handleClickOutside)
return () => document.removeEventListener('click', handleClickOutside)
}
}, [showCustomerDropdown])
// Calculated totals
const totals = useMemo(() => {
const subtotal = items.reduce((sum, item) => {
if (item.is_included_in_total) {
return sum + (Number(item.quantity) || 0) * (Number(item.unit_price) || 0)
}
return sum
}, 0)
const vatAmount = form.apply_vat ? subtotal * ((Number(form.vat_rate) || 0) / 100) : 0
return { subtotal, vatAmount, total: subtotal + vatAmount }
}, [items, form.apply_vat, form.vat_rate])
// Customer filtering
const filteredCustomers = useMemo(() => {
if (!customerSearch) return customers
const q = customerSearch.toLowerCase()
return customers.filter(c =>
(c.name || '').toLowerCase().includes(q) ||
(c.company_id || '').includes(customerSearch) ||
(c.city || '').toLowerCase().includes(q)
)
}, [customers, customerSearch])
// Draft helpers
const clearDraft = useCallback(() => {
try { localStorage.removeItem(DRAFT_KEY) } catch { /* ignore */ }
setDraftSavedAt(null)
}, [])
const draftSavedAtLabel = useMemo(() => {
if (!draftSavedAt) return null
return draftSavedAt.toLocaleTimeString('cs-CZ', { hour: '2-digit', minute: '2-digit' })
}, [draftSavedAt])
// Form handlers
const updateForm = (field, value) => setForm(prev => ({ ...prev, [field]: value }))
const selectCustomer = (customer) => {
setForm(prev => ({
...prev,
customer_id: customer.id,
customer_name: customer.name
}))
setErrors(prev => ({ ...prev, customer_id: undefined }))
setCustomerSearch('')
setShowCustomerDropdown(false)
}
const clearCustomer = () => {
setForm(prev => ({ ...prev, customer_id: null, customer_name: '' }))
}
// Items handlers
const updateItem = (index, field, value) => {
setItems(prev => prev.map((item, i) => i === index ? { ...item, [field]: value } : item))
}
const addItem = () => setItems(prev => [...prev, emptyItem()])
const removeItem = (index) => {
setItems(prev => prev.length > 1 ? prev.filter((_, i) => i !== index) : prev)
}
const addItemFromTemplate = (template) => {
setItems(prev => [...prev, {
_key: `item-${++_keyCounter}`,
description: template.name || '',
item_description: template.description || '',
quantity: 1,
unit: '',
unit_price: Number(template.default_price) || 0,
is_included_in_total: true
}])
setShowItemTemplateMenu(false)
}
// Sections handlers
const addSection = () => setSections(prev => [...prev, emptySection()])
const removeSection = (index) => {
setSections(prev => prev.filter((_, i) => i !== index))
}
const updateSection = (index, field, value) => {
setSections(prev => prev.map((s, i) => i === index ? { ...s, [field]: value } : s))
}
const moveSection = (index, direction) => {
setSections(prev => {
const newSections = [...prev]
const target = index + direction
if (target < 0 || target >= newSections.length) return prev
;[newSections[index], newSections[target]] = [newSections[target], newSections[index]]
return newSections
})
}
const loadScopeTemplate = async (template) => {
try {
const response = await apiFetch(`${API_BASE}/offers-templates.php?action=scope_detail&id=${template.id}`)
const result = await response.json()
if (result.success) {
// Load template-level fields into the quotation form
const tpl = result.data
setForm(prev => ({
...prev,
scope_description: tpl.description || prev.scope_description
}))
// Load sections
if (tpl.sections) {
const newSections = tpl.sections.map(s => ({
_key: `sec-${++_keyCounter}`,
title: s.title || '',
title_cz: s.title_cz || '',
content: s.content || ''
}))
setSections(prev => [...prev, ...newSections])
}
alert.success(`Načtena šablona "${template.name}"`)
}
} catch {
alert.error('Nepodařilo se načíst šablonu')
}
setShowScopeTemplateMenu(false)
}
const handleCreateOrder = async () => { const handleCreateOrder = async () => {
if (!customerOrderNumber.trim()) { if (!customerOrderNumber.trim()) {
@@ -426,79 +84,6 @@ export default function OfferDetail() {
} }
} }
// Save
const handleSave = async () => {
const newErrors = {}
if (!form.customer_id) newErrors.customer_id = 'Vyberte zákazníka'
if (!form.created_at) newErrors.created_at = 'Zadejte datum'
if (!form.valid_until) newErrors.valid_until = 'Zadejte datum'
if (items.length === 0 || items.every(i => !i.description.trim())) {
newErrors.items = 'Přidejte alespoň jednu položku'
}
setErrors(newErrors)
if (Object.keys(newErrors).length > 0) return
setSaving(true)
try {
const payload = {
quotation: {
project_code: form.project_code,
customer_id: form.customer_id,
created_at: form.created_at,
valid_until: form.valid_until,
currency: form.currency,
language: form.language,
vat_rate: form.vat_rate,
apply_vat: form.apply_vat,
exchange_rate: form.exchange_rate || null,
exchange_rate_date: form.exchange_rate_date || null,
scope_title: form.scope_title,
scope_description: form.scope_description
},
items: items.map((item, i) => ({
...item,
position: i + 1
})),
sections: sections.map((s, i) => ({
...s,
position: i + 1
}))
}
const url = isEdit
? `${API_BASE}/offers.php?id=${id}`
: `${API_BASE}/offers.php`
const response = await apiFetch(url, {
method: isEdit ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
const result = await response.json()
if (result.success) {
alert.success(result.message || (isEdit ? 'Nabídka byla uložena' : 'Nabídka byla vytvořena'))
if (!isEdit && result.data?.id) {
clearDraft()
const newId = result.data.id
setTimeout(() => navigate(`/offers/${newId}`, { replace: true }), 300)
}
} else {
alert.error(result.error || 'Nepodařilo se uložit nabídku')
}
} catch {
alert.error('Chyba připojení')
} finally {
setSaving(false)
}
}
const [pdfLoading, setPdfLoading] = useState(false)
const isInvalidated = offerStatus === 'invalidated'
const isExpiredNotInvalidated = isEdit && !isInvalidated && !orderInfo && form.valid_until && new Date(form.valid_until) < new Date(new Date().toDateString())
const handleInvalidateOffer = async () => { const handleInvalidateOffer = async () => {
setInvalidatingOffer(true) setInvalidatingOffer(true)
try { try {
@@ -520,13 +105,6 @@ export default function OfferDetail() {
} }
} }
const getRequiredPerm = () => {
if (!isEdit) return 'offers.create'
return isInvalidated ? 'offers.view' : 'offers.edit'
}
const requiredPerm = getRequiredPerm()
if (!hasPermission(requiredPerm)) return <Forbidden />
const handleDelete = async () => { const handleDelete = async () => {
setDeleting(true) setDeleting(true)
try { try {
@@ -573,6 +151,13 @@ export default function OfferDetail() {
} }
} }
const getRequiredPerm = () => {
if (!isEdit) return 'offers.create'
return isInvalidated ? 'offers.view' : 'offers.edit'
}
const requiredPerm = getRequiredPerm()
if (!hasPermission(requiredPerm)) return <Forbidden />
if (loading) { if (loading) {
return ( return (
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}> <div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}>
@@ -755,54 +340,15 @@ export default function OfferDetail() {
readOnly={isInvalidated} readOnly={isInvalidated}
/> />
</div> </div>
<div className={`admin-form-group${errors.customer_id ? ' has-error' : ''}`}> <OfferCustomerPicker
<label className="admin-form-label required">Zákazník</label> customers={customers}
{form.customer_id && ( customerId={form.customer_id}
<div className="offers-customer-selected"> customerName={form.customer_name}
<span>{form.customer_name}</span> onSelect={selectCustomer}
{!isInvalidated && ( onClear={clearCustomer}
<button type="button" onClick={clearCustomer} className="admin-btn-icon" title="Odebrat zákazníka" aria-label="Odebrat zákazníka"> error={errors.customer_id}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> readOnly={isInvalidated}
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /> />
</svg>
</button>
)}
</div>
)}
{!form.customer_id && !isInvalidated && (
<div className="offers-customer-select" onClick={(e) => e.stopPropagation()}>
<input
type="text"
value={customerSearch}
onChange={(e) => { setCustomerSearch(e.target.value); setShowCustomerDropdown(true) }}
onFocus={() => setShowCustomerDropdown(true)}
className="admin-form-input"
placeholder="Hledat zákazníka..."
/>
{showCustomerDropdown && (
<div className="offers-customer-dropdown">
{filteredCustomers.length === 0 ? (
<div className="offers-customer-dropdown-empty">
Žádní zákazníci
</div>
) : (
filteredCustomers.slice(0, 10).map(c => (
<div
key={c.id}
className="offers-customer-dropdown-item"
onMouseDown={() => selectCustomer(c)}
>
<div>{c.name}</div>
{c.city && <div>{c.city}</div>}
</div>
))
)}
</div>
)}
</div>
)}
{errors.customer_id && <span className="admin-form-error">{errors.customer_id}</span>}
</div>
</div> </div>
<div className="admin-form-row"> <div className="admin-form-row">
@@ -849,10 +395,10 @@ export default function OfferDetail() {
className="admin-form-select" className="admin-form-select"
disabled={isInvalidated} disabled={isInvalidated}
> >
<option value="EUR">EUR ()</option> <option value="EUR">EUR (&euro;)</option>
<option value="USD">USD ($)</option> <option value="USD">USD ($)</option>
<option value="CZK">CZK ()</option> <option value="CZK">CZK ()</option>
<option value="GBP">GBP (£)</option> <option value="GBP">GBP (&pound;)</option>
</select> </select>
</div> </div>
<div className="admin-form-group"> <div className="admin-form-group">

View File

@@ -12,6 +12,7 @@ import SortIcon from '../components/SortIcon'
import useTableSort from '../hooks/useTableSort' import useTableSort from '../hooks/useTableSort'
import useListData from '../hooks/useListData' import useListData from '../hooks/useListData'
import useModalLock from '../hooks/useModalLock' import useModalLock from '../hooks/useModalLock'
import Pagination from '../components/Pagination'
const API_BASE = '/api/admin' const API_BASE = '/api/admin'
const DRAFT_KEY = 'boha_offer_draft' const DRAFT_KEY = 'boha_offer_draft'
@@ -22,6 +23,7 @@ export default function Offers() {
const { sort, order, handleSort, activeSort } = useTableSort('quotation_number') const { sort, order, handleSort, activeSort } = useTableSort('quotation_number')
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [page, setPage] = useState(1)
const [deleteConfirm, setDeleteConfirm] = useState({ show: false, quotation: null }) const [deleteConfirm, setDeleteConfirm] = useState({ show: false, quotation: null })
const [deleting, setDeleting] = useState(false) const [deleting, setDeleting] = useState(false)
@@ -36,8 +38,8 @@ export default function Offers() {
const [orderAttachment, setOrderAttachment] = useState(null) const [orderAttachment, setOrderAttachment] = useState(null)
const [draft, setDraft] = useState(null) const [draft, setDraft] = useState(null)
const { items: quotations, loading, refetch: fetchData } = useListData('offers.php', { const { items: quotations, loading, pagination, refetch: fetchData } = useListData('offers.php', {
dataKey: 'quotations', search, sort, order, dataKey: 'quotations', search, sort, order, page,
errorMsg: 'Nepodařilo se načíst nabídky' errorMsg: 'Nepodařilo se načíst nabídky'
}) })
@@ -232,7 +234,7 @@ export default function Offers() {
<div> <div>
<h1 className="admin-page-title">Nabídky</h1> <h1 className="admin-page-title">Nabídky</h1>
<p className="admin-page-subtitle"> <p className="admin-page-subtitle">
{quotations.length} {czechPlural(quotations.length, 'nabídka', 'nabídky', 'nabídek')} {pagination?.total ?? quotations.length} {czechPlural(pagination?.total ?? quotations.length, 'nabídka', 'nabídky', 'nabídek')}
</p> </p>
</div> </div>
<div className="admin-page-actions"> <div className="admin-page-actions">
@@ -268,7 +270,7 @@ export default function Offers() {
<input <input
type="text" type="text"
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => { setSearch(e.target.value); setPage(1) }}
className="admin-form-input" className="admin-form-input"
placeholder="Hledat podle čísla, projektu nebo zákazníka..." placeholder="Hledat podle čísla, projektu nebo zákazníka..."
/> />
@@ -509,6 +511,7 @@ export default function Offers() {
</table> </table>
</div> </div>
)} )}
<Pagination pagination={pagination} onPageChange={setPage} />
</div> </div>
</motion.div> </motion.div>

View File

@@ -11,6 +11,7 @@ import { formatCurrency, formatDate, czechPlural } from '../utils/formatters'
import SortIcon from '../components/SortIcon' import SortIcon from '../components/SortIcon'
import useTableSort from '../hooks/useTableSort' import useTableSort from '../hooks/useTableSort'
import useListData from '../hooks/useListData' import useListData from '../hooks/useListData'
import Pagination from '../components/Pagination'
const API_BASE = '/api/admin' const API_BASE = '/api/admin'
const STATUS_LABELS = { const STATUS_LABELS = {
@@ -33,12 +34,13 @@ export default function Orders() {
const { sort, order, handleSort, activeSort } = useTableSort('order_number') const { sort, order, handleSort, activeSort } = useTableSort('order_number')
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [page, setPage] = useState(1)
const [deleteConfirm, setDeleteConfirm] = useState({ show: false, order: null }) const [deleteConfirm, setDeleteConfirm] = useState({ show: false, order: null })
const [deleting, setDeleting] = useState(false) const [deleting, setDeleting] = useState(false)
const { items: orders, loading, refetch: fetchData } = useListData('orders.php', { const { items: orders, loading, pagination, refetch: fetchData } = useListData('orders.php', {
dataKey: 'orders', search, sort, order, dataKey: 'orders', search, sort, order, page,
errorMsg: 'Nepodařilo se načíst objednávky' errorMsg: 'Nepodařilo se načíst objednávky'
}) })
@@ -137,7 +139,7 @@ export default function Orders() {
<div> <div>
<h1 className="admin-page-title">Objednávky</h1> <h1 className="admin-page-title">Objednávky</h1>
<p className="admin-page-subtitle"> <p className="admin-page-subtitle">
{orders.length} {czechPlural(orders.length, 'objednávka', 'objednávky', 'objednávek')} {pagination?.total ?? orders.length} {czechPlural(pagination?.total ?? orders.length, 'objednávka', 'objednávky', 'objednávek')}
</p> </p>
</div> </div>
</motion.div> </motion.div>
@@ -153,7 +155,7 @@ export default function Orders() {
<input <input
type="text" type="text"
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => { setSearch(e.target.value); setPage(1) }}
className="admin-form-input" className="admin-form-input"
placeholder="Hledat podle čísla, nabídky, projektu nebo zákazníka..." placeholder="Hledat podle čísla, nabídky, projektu nebo zákazníka..."
/> />
@@ -264,6 +266,7 @@ export default function Orders() {
</table> </table>
</div> </div>
)} )}
<Pagination pagination={pagination} onPageChange={setPage} />
</div> </div>
</motion.div> </motion.div>

View File

@@ -11,6 +11,7 @@ import { formatDate, czechPlural } from '../utils/formatters'
import SortIcon from '../components/SortIcon' import SortIcon from '../components/SortIcon'
import useTableSort from '../hooks/useTableSort' import useTableSort from '../hooks/useTableSort'
import useListData from '../hooks/useListData' import useListData from '../hooks/useListData'
import Pagination from '../components/Pagination'
const API_BASE = '/api/admin' const API_BASE = '/api/admin'
const STATUS_LABELS = { const STATUS_LABELS = {
@@ -31,11 +32,12 @@ export default function Projects() {
const { sort, order, handleSort, activeSort } = useTableSort('project_number') const { sort, order, handleSort, activeSort } = useTableSort('project_number')
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [page, setPage] = useState(1)
const [deletingId, setDeletingId] = useState(null) const [deletingId, setDeletingId] = useState(null)
const [deleteTarget, setDeleteTarget] = useState(null) const [deleteTarget, setDeleteTarget] = useState(null)
const { items: projects, setItems: setProjects, loading } = useListData('projects.php', { const { items: projects, setItems: setProjects, loading, pagination } = useListData('projects.php', {
dataKey: 'projects', search, sort, order, dataKey: 'projects', search, sort, order, page,
errorMsg: 'Nepodařilo se načíst projekty' errorMsg: 'Nepodařilo se načíst projekty'
}) })
@@ -132,7 +134,7 @@ export default function Projects() {
<div> <div>
<h1 className="admin-page-title">Projekty</h1> <h1 className="admin-page-title">Projekty</h1>
<p className="admin-page-subtitle"> <p className="admin-page-subtitle">
{projects.length} {czechPlural(projects.length, 'projekt', 'projekty', 'projektů')} {pagination?.total ?? projects.length} {czechPlural(pagination?.total ?? projects.length, 'projekt', 'projekty', 'projektů')}
</p> </p>
</div> </div>
{hasPermission('projects.create') && ( {hasPermission('projects.create') && (
@@ -157,7 +159,7 @@ export default function Projects() {
<input <input
type="text" type="text"
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => { setSearch(e.target.value); setPage(1) }}
className="admin-form-input" className="admin-form-input"
placeholder="Hledat podle čísla, názvu nebo zákazníka..." placeholder="Hledat podle čísla, názvu nebo zákazníka..."
/> />
@@ -258,6 +260,7 @@ export default function Projects() {
</table> </table>
</div> </div>
)} )}
<Pagination pagination={pagination} onPageChange={setPage} />
</div> </div>
</motion.div> </motion.div>

View File

@@ -8,6 +8,7 @@ import useModalLock from '../hooks/useModalLock'
import { formatKm } from '../utils/formatters' import { formatKm } from '../utils/formatters'
import apiFetch from '../utils/api' import apiFetch from '../utils/api'
import FormField from '../components/FormField'
const API_BASE = '/api/admin' const API_BASE = '/api/admin'
export default function Vehicles() { export default function Vehicles() {
@@ -338,8 +339,7 @@ export default function Vehicles() {
<div className="admin-modal-body"> <div className="admin-modal-body">
<div className="admin-form"> <div className="admin-form">
<div className="admin-form-row"> <div className="admin-form-row">
<div className={`admin-form-group${errors.spz ? ' has-error' : ''}`}> <FormField label="SPZ" error={errors.spz} required>
<label className="admin-form-label required">SPZ</label>
<input <input
type="text" type="text"
value={form.spz} value={form.spz}
@@ -349,12 +349,11 @@ export default function Vehicles() {
}} }}
className="admin-form-input" className="admin-form-input"
placeholder="1AB 2345" placeholder="1AB 2345"
aria-invalid={!!errors.spz}
/> />
{errors.spz && <span className="admin-form-error">{errors.spz}</span>} </FormField>
</div>
<div className={`admin-form-group${errors.name ? ' has-error' : ''}`}> <FormField label="Název" error={errors.name} required>
<label className="admin-form-label required">Název</label>
<input <input
type="text" type="text"
value={form.name} value={form.name}
@@ -364,14 +363,13 @@ export default function Vehicles() {
}} }}
className="admin-form-input" className="admin-form-input"
placeholder="Služební #1" placeholder="Služební #1"
aria-invalid={!!errors.name}
/> />
{errors.name && <span className="admin-form-error">{errors.name}</span>} </FormField>
</div>
</div> </div>
<div className="admin-form-row"> <div className="admin-form-row">
<div className="admin-form-group"> <FormField label="Značka">
<label className="admin-form-label">Značka</label>
<input <input
type="text" type="text"
value={form.brand} value={form.brand}
@@ -379,10 +377,9 @@ export default function Vehicles() {
className="admin-form-input" className="admin-form-input"
placeholder="Škoda" placeholder="Škoda"
/> />
</div> </FormField>
<div className="admin-form-group"> <FormField label="Model">
<label className="admin-form-label">Model</label>
<input <input
type="text" type="text"
value={form.model} value={form.model}
@@ -390,7 +387,7 @@ export default function Vehicles() {
className="admin-form-input" className="admin-form-input"
placeholder="Octavia Combi" placeholder="Octavia Combi"
/> />
</div> </FormField>
</div> </div>
<div className="admin-form-group"> <div className="admin-form-group">

View File

@@ -0,0 +1,85 @@
export const LEAVE_TYPE_LABELS = {
vacation: 'Dovolená',
sick: 'Nemoc',
holiday: 'Svátek',
unpaid: 'Neplacené volno',
}
export const STATUS_DOT_CLASS = {
in: 'dash-status-in',
away: 'dash-status-away',
out: 'dash-status-out',
leave: 'dash-status-leave',
}
export const STATUS_LABELS = {
in: 'Přítomen',
away: 'Přestávka',
out: 'Nepřihlášen',
leave: 'Nepřítomen',
}
export const ENTITY_TYPE_LABELS = {
user: 'Uživatel',
attendance: 'Docházka',
leave_request: 'Žádost o nepřítomnost',
offers_quotation: 'Nabídka',
offers_customer: 'Zákazník',
offers_item_template: 'Šablona položky',
offers_scope_template: 'Šablona rozsahu',
offers_settings: 'Nastavení nabídek',
orders_order: 'Objednávka',
invoices_invoice: 'Faktura',
projects_project: 'Projekt',
role: 'Role',
trips: 'Jízda',
vehicles: 'Vozidlo',
bank_account: 'Bankovní účet',
}
const ACTION_LABELS = {
create: 'Vytvořil',
update: 'Upravil',
delete: 'Smazal',
login: 'Přihlášení',
}
export { ACTION_LABELS }
export function getCzechDate() {
const now = new Date()
const days = ['Neděle', 'Pondělí', 'Úterý', 'Středa', 'Čtvrtek', 'Pátek', 'Sobota']
const months = ['ledna', 'února', 'března', 'dubna', 'května', 'června', 'července', 'srpna', 'září', 'října', 'listopadu', 'prosince']
const day = days[now.getDay()]
const oneJan = new Date(now.getFullYear(), 0, 1)
const week = Math.ceil(((now.getTime() - oneJan.getTime()) / 86400000 + oneJan.getDay() + 1) / 7)
return `${day}, ${now.getDate()}. ${months[now.getMonth()]} ${now.getFullYear()} · Týden ${week}`
}
export function getActivityIconClass(action) {
const map = { create: 'success', update: 'info', delete: 'danger', login: 'accent' }
return map[action] || 'muted'
}
export function formatActivityTime(dateString) {
const date = new Date(dateString)
const now = new Date()
const diff = now.getTime() - date.getTime()
if (diff < 60000) {
return 'Právě teď'
}
if (diff < 3600000) {
return `${Math.floor(diff / 60000)} min`
}
if (date.toDateString() === now.toDateString()) {
return date.toLocaleTimeString('cs-CZ', { hour: '2-digit', minute: '2-digit' })
}
return date.toLocaleDateString('cs-CZ', { day: '2-digit', month: '2-digit' })
}
export function formatSessionDate(dateString) {
const date = new Date(dateString)
return date.toLocaleDateString('cs-CZ', {
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit'
})
}

View File

@@ -41,14 +41,21 @@ import { defineConfig } from 'vite'
try { try {
if (existsSync(vendorSrc)) { if (existsSync(vendorSrc)) {
execSync('composer install --no-dev --quiet', { cwd: resolve(__dirname) }) try {
execSync('composer install --no-dev --quiet', { cwd: resolve(__dirname) })
} catch {
console.warn('⚠ composer not found, copying vendor as-is')
}
copyFolderSync(vendorSrc, vendorDest) copyFolderSync(vendorSrc, vendorDest)
execSync('composer install --quiet', { cwd: resolve(__dirname) }) try {
console.log('✓ Vendor folder copied to dist/vendor (production only)') execSync('composer install --quiet', { cwd: resolve(__dirname) })
} catch {
// composer not in PATH - dev deps already present
}
console.log('✓ Vendor folder copied to dist/vendor')
} }
} catch (err) { } catch (err) {
console.error('Error copying vendor folder:', err) console.error('Error copying vendor folder:', err)
execSync('composer install --quiet', { cwd: resolve(__dirname) })
} }
console.log('✓ Build complete!') console.log('✓ Build complete!')