Compare commits
7 Commits
bb2bbb8ff6
...
21f08593e4
| Author | SHA1 | Date | |
|---|---|---|---|
| 21f08593e4 | |||
| f88ae25057 | |||
| ec44895f3d | |||
| 758be819c3 | |||
| df506dfea4 | |||
| 6863c7c557 | |||
| 6ad20ea04e |
62
api/admin/audit-log.php
Normal file
62
api/admin/audit-log.php
Normal 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);
|
||||
@@ -7,7 +7,11 @@ function handleGetCurrent(PDO $pdo, int $userId): void
|
||||
$today = date('Y-m-d');
|
||||
|
||||
$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')
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
");
|
||||
@@ -17,7 +21,10 @@ function handleGetCurrent(PDO $pdo, int $userId): void
|
||||
$projectLogs = [];
|
||||
$activeProjectId = null;
|
||||
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']]);
|
||||
$projectLogs = $stmt->fetchAll();
|
||||
foreach ($projectLogs as $log) {
|
||||
@@ -29,7 +36,11 @@ function handleGetCurrent(PDO $pdo, int $userId): void
|
||||
}
|
||||
|
||||
$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 = ?
|
||||
AND departure_time IS NOT NULL
|
||||
AND (leave_type IS NULL OR leave_type = 'work')
|
||||
@@ -43,7 +54,8 @@ function handleGetCurrent(PDO $pdo, int $userId): void
|
||||
if (!empty($completedShiftIds)) {
|
||||
$placeholders = implode(',', array_fill(0, count($completedShiftIds), '?'));
|
||||
$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)
|
||||
ORDER BY started_at ASC"
|
||||
);
|
||||
@@ -65,7 +77,9 @@ function handleGetCurrent(PDO $pdo, int $userId): void
|
||||
$endDate = date('Y-m-t');
|
||||
|
||||
$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 ?
|
||||
');
|
||||
$stmt->execute([$userId, $startDate, $endDate]);
|
||||
@@ -167,7 +181,10 @@ function handleGetHistory(PDO $pdo, int $userId): void
|
||||
$endDate = date('Y-m-t', strtotime($startDate));
|
||||
|
||||
$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 ?
|
||||
ORDER BY shift_date DESC
|
||||
');
|
||||
@@ -245,7 +262,9 @@ function handlePunch(PDO $pdo, int $userId): void
|
||||
$address = !empty($input['address']) ? $input['address'] : null;
|
||||
|
||||
$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')
|
||||
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]);
|
||||
$logs = $stmt->fetchAll();
|
||||
|
||||
@@ -556,7 +578,7 @@ function handleSaveProjectLogs(PDO $pdo): void
|
||||
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]);
|
||||
$record = $stmt->fetch();
|
||||
if (!$record) {
|
||||
|
||||
@@ -4,7 +4,11 @@ declare(strict_types=1);
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -78,7 +82,11 @@ function handleCreateBankAccount(PDO $pdo): 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]);
|
||||
$account = $stmt->fetch();
|
||||
|
||||
@@ -145,7 +153,7 @@ function handleUpdateBankAccount(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]);
|
||||
$account = $stmt->fetch();
|
||||
|
||||
|
||||
@@ -9,7 +9,13 @@ declare(strict_types=1);
|
||||
function getOrCreateSettings(PDO $pdo, bool $includeLogo = false): array
|
||||
{
|
||||
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 {
|
||||
$stmt = $pdo->query('
|
||||
SELECT id, company_name, company_id, vat_id, street, city, postal_code, country,
|
||||
|
||||
@@ -54,7 +54,9 @@ function encodeCustomerCustomFields(array $input, ?string $existingJson): ?strin
|
||||
function handleGetAll(PDO $pdo): void
|
||||
{
|
||||
$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
|
||||
LEFT JOIN quotations q ON q.customer_id = c.id
|
||||
GROUP BY c.id
|
||||
@@ -72,7 +74,11 @@ function handleGetAll(PDO $pdo): 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]);
|
||||
$customer = $stmt->fetch();
|
||||
|
||||
@@ -93,7 +99,9 @@ function handleSearch(PDO $pdo): void
|
||||
}
|
||||
|
||||
$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 ?
|
||||
ORDER BY name ASC
|
||||
LIMIT 20
|
||||
@@ -177,7 +185,11 @@ function handleCreateCustomer(PDO $pdo): 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]);
|
||||
$existing = $stmt->fetch();
|
||||
|
||||
@@ -248,7 +260,7 @@ function handleUpdateCustomer(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]);
|
||||
$customer = $stmt->fetch();
|
||||
|
||||
|
||||
@@ -99,9 +99,6 @@ function handleGetStats(PDO $pdo): void
|
||||
$month = max(1, min(12, (int) ($_GET['month'] ?? (int) date('n'))));
|
||||
$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);
|
||||
$monthEnd = date('Y-m-t', strtotime($monthStart));
|
||||
|
||||
@@ -169,12 +166,7 @@ function handleGetStats(PDO $pdo): void
|
||||
|
||||
function handleGetList(PDO $pdo): void
|
||||
{
|
||||
$search = trim($_GET['search'] ?? '');
|
||||
$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 = [
|
||||
'InvoiceNumber' => 'i.invoice_number',
|
||||
@@ -188,21 +180,15 @@ function handleGetList(PDO $pdo): void
|
||||
'IssueDate' => 'i.issue_date',
|
||||
'issue_date' => 'i.issue_date',
|
||||
];
|
||||
if (!isset($sortMap[$sort])) {
|
||||
errorResponse('Neplatný parametr řazení', 400);
|
||||
}
|
||||
$sortCol = $sortMap[$sort];
|
||||
|
||||
// Lazy overdue detekce
|
||||
$pdo->exec("UPDATE invoices SET status = 'overdue' WHERE status = 'issued' AND due_date < CURDATE()");
|
||||
$p = PaginationHelper::parseParams($sortMap);
|
||||
|
||||
$where = 'WHERE 1=1';
|
||||
$params = [];
|
||||
|
||||
if ($search) {
|
||||
$search = mb_substr($search, 0, 100);
|
||||
if ($p['search']) {
|
||||
$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]);
|
||||
}
|
||||
|
||||
@@ -215,36 +201,26 @@ function handleGetList(PDO $pdo): void
|
||||
}
|
||||
}
|
||||
|
||||
$countSql = "
|
||||
SELECT COUNT(*)
|
||||
FROM invoices i
|
||||
$from = "FROM invoices i
|
||||
LEFT JOIN customers c ON i.customer_id = c.id
|
||||
$where
|
||||
";
|
||||
$stmt = $pdo->prepare($countSql);
|
||||
$stmt->execute($params);
|
||||
$total = (int) $stmt->fetchColumn();
|
||||
LEFT JOIN orders o ON i.order_id = o.id";
|
||||
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
$sql = "
|
||||
SELECT i.id, i.invoice_number, i.order_id, i.status, i.currency,
|
||||
$result = PaginationHelper::paginate(
|
||||
$pdo,
|
||||
"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,
|
||||
i.issue_date, i.due_date, i.paid_date, i.created_at, i.apply_vat,
|
||||
c.name as customer_name,
|
||||
(SELECT COALESCE(SUM(ii.quantity * ii.unit_price), 0)
|
||||
FROM invoice_items ii WHERE ii.invoice_id = i.id) as subtotal,
|
||||
o.order_number
|
||||
FROM invoices i
|
||||
LEFT JOIN customers c ON i.customer_id = c.id
|
||||
LEFT JOIN orders o ON i.order_id = o.id
|
||||
$where
|
||||
ORDER BY $sortCol $order
|
||||
LIMIT $perPage OFFSET $offset
|
||||
";
|
||||
{$from} {$where}
|
||||
ORDER BY {$p['sort']} {$p['order']}",
|
||||
$params,
|
||||
$p
|
||||
);
|
||||
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$invoices = $stmt->fetchAll();
|
||||
$invoices = $result['items'];
|
||||
|
||||
// Dopocitat celkovou castku s DPH
|
||||
foreach ($invoices as &$inv) {
|
||||
@@ -265,21 +241,20 @@ function handleGetList(PDO $pdo): void
|
||||
|
||||
successResponse([
|
||||
'invoices' => $invoices,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'per_page' => $perPage,
|
||||
'pagination' => $result['pagination'],
|
||||
]);
|
||||
}
|
||||
|
||||
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('
|
||||
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
|
||||
LEFT JOIN customers c ON i.customer_id = c.id
|
||||
LEFT JOIN orders o ON i.order_id = o.id
|
||||
@@ -293,7 +268,10 @@ function handleGetDetail(PDO $pdo, int $id): void
|
||||
}
|
||||
|
||||
// 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]);
|
||||
$invoice['items'] = $stmt->fetchAll();
|
||||
|
||||
@@ -337,7 +315,11 @@ function handleGetOrderData(PDO $pdo, int $id): void
|
||||
}
|
||||
|
||||
// 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]);
|
||||
$order['items'] = $stmt->fetchAll();
|
||||
|
||||
@@ -526,7 +508,14 @@ function handleCreateInvoice(PDO $pdo, array $authData): 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]);
|
||||
$invoice = $stmt->fetch();
|
||||
|
||||
@@ -677,7 +666,9 @@ function handleUpdateInvoice(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]);
|
||||
$invoice = $stmt->fetch();
|
||||
|
||||
|
||||
@@ -31,7 +31,10 @@ function getLeaveBalanceForRequest(PDO $pdo, int $userId, ?int $year = null): ar
|
||||
{
|
||||
$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]);
|
||||
$balance = $stmt->fetch();
|
||||
|
||||
@@ -77,7 +80,9 @@ function getPendingVacationHours(PDO $pdo, int $userId, int $year): float
|
||||
function handleGetMyRequests(PDO $pdo, int $userId): void
|
||||
{
|
||||
$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
|
||||
FROM leave_requests lr
|
||||
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
|
||||
{
|
||||
$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(rv.first_name, ' ', rv.last_name) as reviewer_name
|
||||
FROM leave_requests lr
|
||||
@@ -138,7 +145,9 @@ function handleGetAll(PDO $pdo): void
|
||||
$whereClause = $where ? 'WHERE ' . implode(' AND ', $where) : '';
|
||||
|
||||
$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(rv.first_name, ' ', rv.last_name) as reviewer_name
|
||||
FROM leave_requests lr
|
||||
@@ -270,7 +279,11 @@ function handleCancelRequest(PDO $pdo, int $userId): void
|
||||
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]);
|
||||
$request = $stmt->fetch();
|
||||
|
||||
@@ -310,7 +323,11 @@ function handleApproveRequest(PDO $pdo, int $reviewerId, array $authData): void
|
||||
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]);
|
||||
$request = $stmt->fetch();
|
||||
|
||||
@@ -427,7 +444,9 @@ function handleRejectRequest(PDO $pdo, int $reviewerId, array $authData): void
|
||||
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]);
|
||||
$request = $stmt->fetch();
|
||||
|
||||
|
||||
@@ -4,12 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
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 = [
|
||||
'Date' => 'q.created_at',
|
||||
'CreatedAt' => 'q.created_at',
|
||||
@@ -23,64 +17,48 @@ function handleGetList(PDO $pdo): void
|
||||
'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';
|
||||
$params = [];
|
||||
|
||||
if ($search) {
|
||||
$search = mb_substr($search, 0, 100);
|
||||
if ($p['search']) {
|
||||
$where .= ' AND (q.quotation_number LIKE ? OR q.project_code LIKE ? OR c.name LIKE ?)';
|
||||
$searchParam = "%{$search}%";
|
||||
$searchParam = "%{$p['search']}%";
|
||||
$params = [$searchParam, $searchParam, $searchParam];
|
||||
}
|
||||
|
||||
// Celkovy pocet pro pagination
|
||||
$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();
|
||||
$from = "FROM quotations q LEFT JOIN customers c ON q.customer_id = c.id";
|
||||
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
$sql = "
|
||||
SELECT q.id, q.quotation_number, q.project_code, q.created_at, q.valid_until,
|
||||
$result = PaginationHelper::paginate(
|
||||
$pdo,
|
||||
"SELECT COUNT(*) {$from} {$where}",
|
||||
"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.customer_id, q.order_id, q.status,
|
||||
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)
|
||||
FROM quotation_items qi WHERE qi.quotation_id = q.id) as total
|
||||
FROM quotations q
|
||||
LEFT JOIN customers c ON q.customer_id = c.id
|
||||
$where
|
||||
ORDER BY $sortCol $order
|
||||
LIMIT $perPage OFFSET $offset
|
||||
";
|
||||
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$quotations = $stmt->fetchAll();
|
||||
{$from} {$where}
|
||||
ORDER BY {$p['sort']} {$p['order']}",
|
||||
$params,
|
||||
$p
|
||||
);
|
||||
|
||||
successResponse([
|
||||
'quotations' => $quotations,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'per_page' => $perPage,
|
||||
'quotations' => $result['items'],
|
||||
'pagination' => $result['pagination'],
|
||||
]);
|
||||
}
|
||||
|
||||
function handleGetDetail(PDO $pdo, int $id): void
|
||||
{
|
||||
$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
|
||||
LEFT JOIN customers c ON q.customer_id = c.id
|
||||
WHERE q.id = ?
|
||||
@@ -94,7 +72,9 @@ function handleGetDetail(PDO $pdo, int $id): void
|
||||
|
||||
// Get items
|
||||
$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 = ?
|
||||
ORDER BY position
|
||||
');
|
||||
@@ -103,7 +83,8 @@ function handleGetDetail(PDO $pdo, int $id): void
|
||||
|
||||
// Get scope sections
|
||||
$stmt = $pdo->prepare('
|
||||
SELECT * FROM scope_sections
|
||||
SELECT id, quotation_id, position, title, title_cz, content
|
||||
FROM scope_sections
|
||||
WHERE quotation_id = ?
|
||||
ORDER BY position
|
||||
');
|
||||
@@ -290,7 +271,12 @@ function handleCreateOffer(PDO $pdo): 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]);
|
||||
$existing = $stmt->fetch();
|
||||
|
||||
@@ -375,7 +361,12 @@ function handleUpdateOffer(PDO $pdo, int $id): 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]);
|
||||
$source = $stmt->fetch();
|
||||
|
||||
@@ -383,11 +374,18 @@ function handleDuplicate(PDO $pdo, int $sourceId): void
|
||||
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]);
|
||||
$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]);
|
||||
$sourceSections = $stmt->fetchAll();
|
||||
|
||||
|
||||
@@ -4,7 +4,10 @@ declare(strict_types=1);
|
||||
|
||||
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()]);
|
||||
}
|
||||
|
||||
@@ -100,13 +103,17 @@ function handleDeleteItemTemplate(PDO $pdo, int $id): 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()]);
|
||||
}
|
||||
|
||||
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]);
|
||||
$template = $stmt->fetch();
|
||||
|
||||
@@ -114,7 +121,10 @@ function handleGetScopeDetail(PDO $pdo, int $id): void
|
||||
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]);
|
||||
$template['sections'] = $stmt->fetchAll();
|
||||
|
||||
|
||||
@@ -25,12 +25,6 @@ function generateOrderNumber(PDO $pdo): string
|
||||
|
||||
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 = [
|
||||
'OrderNumber' => 'o.order_number',
|
||||
'order_number' => 'o.order_number',
|
||||
@@ -41,36 +35,25 @@ function handleGetList(PDO $pdo): void
|
||||
'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';
|
||||
$params = [];
|
||||
|
||||
if ($search) {
|
||||
$search = mb_substr($search, 0, 100);
|
||||
if ($p['search']) {
|
||||
$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];
|
||||
}
|
||||
|
||||
$countSql = "
|
||||
SELECT COUNT(*)
|
||||
FROM orders o
|
||||
$from = "FROM orders o
|
||||
LEFT JOIN quotations q ON o.quotation_id = q.id
|
||||
LEFT JOIN customers c ON o.customer_id = c.id
|
||||
$where
|
||||
";
|
||||
$stmt = $pdo->prepare($countSql);
|
||||
$stmt->execute($params);
|
||||
$total = (int) $stmt->fetchColumn();
|
||||
LEFT JOIN customers c ON o.customer_id = c.id";
|
||||
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
$sql = "
|
||||
SELECT o.id, o.order_number, o.quotation_id, o.status, o.currency,
|
||||
$result = PaginationHelper::paginate(
|
||||
$pdo,
|
||||
"SELECT COUNT(*) {$from} {$where}",
|
||||
"SELECT o.id, o.order_number, o.quotation_id, o.status, o.currency,
|
||||
o.created_at, o.apply_vat, o.vat_rate,
|
||||
q.quotation_number, q.project_code,
|
||||
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,
|
||||
(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
|
||||
FROM orders o
|
||||
LEFT JOIN quotations q ON o.quotation_id = q.id
|
||||
LEFT JOIN customers c ON o.customer_id = c.id
|
||||
$where
|
||||
ORDER BY $sortCol $order
|
||||
LIMIT $perPage OFFSET $offset
|
||||
";
|
||||
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$orders = $stmt->fetchAll();
|
||||
{$from} {$where}
|
||||
ORDER BY {$p['sort']} {$p['order']}",
|
||||
$params,
|
||||
$p
|
||||
);
|
||||
|
||||
successResponse([
|
||||
'orders' => $orders,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'per_page' => $perPage,
|
||||
'orders' => $result['items'],
|
||||
'pagination' => $result['pagination'],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -121,12 +96,19 @@ function handleGetDetail(PDO $pdo, int $id): void
|
||||
}
|
||||
|
||||
// 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]);
|
||||
$order['items'] = $stmt->fetchAll();
|
||||
|
||||
// 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]);
|
||||
$order['sections'] = $stmt->fetchAll();
|
||||
|
||||
@@ -227,7 +209,12 @@ function handleCreateOrder(PDO $pdo): void
|
||||
}
|
||||
|
||||
// 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]);
|
||||
$quotation = $stmt->fetch();
|
||||
|
||||
@@ -240,11 +227,18 @@ function handleCreateOrder(PDO $pdo): void
|
||||
}
|
||||
|
||||
// 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]);
|
||||
$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]);
|
||||
$quotationSections = $stmt->fetchAll();
|
||||
|
||||
@@ -379,7 +373,9 @@ function handleCreateOrder(PDO $pdo): 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]);
|
||||
$order = $stmt->fetch();
|
||||
|
||||
@@ -486,7 +482,9 @@ function handleUpdateOrder(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]);
|
||||
$order = $stmt->fetch();
|
||||
|
||||
|
||||
@@ -114,7 +114,9 @@ function handleCreateProject(PDO $pdo): 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]);
|
||||
$project = $stmt->fetch();
|
||||
|
||||
@@ -156,12 +158,6 @@ function handleDeleteProject(PDO $pdo, int $id): 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 = [
|
||||
'ProjectNumber' => 'p.project_number',
|
||||
'project_number' => 'p.project_number',
|
||||
@@ -176,63 +172,47 @@ function handleGetList(PDO $pdo): void
|
||||
'CreatedAt' => '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';
|
||||
$params = [];
|
||||
|
||||
if ($search) {
|
||||
$search = mb_substr($search, 0, 100);
|
||||
if ($p['search']) {
|
||||
$where .= ' AND (p.project_number LIKE ? OR p.name LIKE ? OR c.name LIKE ?)';
|
||||
$searchParam = "%{$search}%";
|
||||
$searchParam = "%{$p['search']}%";
|
||||
$params = [$searchParam, $searchParam, $searchParam];
|
||||
}
|
||||
|
||||
$countSql = "
|
||||
SELECT COUNT(*)
|
||||
FROM projects p
|
||||
$from = "FROM projects p
|
||||
LEFT JOIN customers c ON p.customer_id = c.id
|
||||
LEFT JOIN orders o ON p.order_id = o.id
|
||||
$where
|
||||
";
|
||||
$stmt = $pdo->prepare($countSql);
|
||||
$stmt->execute($params);
|
||||
$total = (int) $stmt->fetchColumn();
|
||||
LEFT JOIN orders o ON p.order_id = o.id";
|
||||
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
$sql = "
|
||||
SELECT p.id, p.project_number, p.name, p.status, p.start_date, p.end_date,
|
||||
$result = PaginationHelper::paginate(
|
||||
$pdo,
|
||||
"SELECT COUNT(*) {$from} {$where}",
|
||||
"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,
|
||||
c.name as customer_name,
|
||||
o.order_number
|
||||
FROM projects p
|
||||
LEFT JOIN customers c ON p.customer_id = c.id
|
||||
LEFT JOIN orders o ON p.order_id = o.id
|
||||
$where
|
||||
ORDER BY $sortCol $order
|
||||
LIMIT $perPage OFFSET $offset
|
||||
";
|
||||
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$projects = $stmt->fetchAll();
|
||||
{$from} {$where}
|
||||
ORDER BY {$p['sort']} {$p['order']}",
|
||||
$params,
|
||||
$p
|
||||
);
|
||||
|
||||
successResponse([
|
||||
'projects' => $projects,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'per_page' => $perPage,
|
||||
'projects' => $result['items'],
|
||||
'pagination' => $result['pagination'],
|
||||
]);
|
||||
}
|
||||
|
||||
function handleGetDetail(PDO $pdo, int $id): void
|
||||
{
|
||||
$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,
|
||||
o.order_number, o.status as order_status,
|
||||
q.quotation_number
|
||||
@@ -254,7 +234,10 @@ function handleGetDetail(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]);
|
||||
$project = $stmt->fetch();
|
||||
|
||||
|
||||
@@ -358,7 +358,12 @@ function handleBulkUpload(PDO $pdo, array $authData): 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]);
|
||||
$invoice = $stmt->fetch();
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@ function handleGetRole(PDO $pdo): void
|
||||
{
|
||||
// Get all roles with user count (LEFT JOIN instead of correlated subquery)
|
||||
$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
|
||||
LEFT JOIN users u ON u.role_id = r.id
|
||||
GROUP BY r.id
|
||||
@@ -133,7 +134,9 @@ function handleCreateRole(PDO $pdo): void
|
||||
function handleUpdateRole(PDO $pdo, int $roleId): void
|
||||
{
|
||||
// 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]);
|
||||
$role = $stmt->fetch();
|
||||
|
||||
@@ -205,7 +208,9 @@ function handleUpdateRole(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]);
|
||||
$role = $stmt->fetch();
|
||||
|
||||
|
||||
@@ -181,7 +181,9 @@ function handleVerify(PDO $pdo, TwoFactorAuth $tfa): void
|
||||
$userId = $tokenData['user_id'];
|
||||
|
||||
$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
|
||||
LEFT JOIN roles r ON u.role_id = r.id
|
||||
WHERE u.id = ? AND u.totp_enabled = 1
|
||||
@@ -230,7 +232,9 @@ function handleBackupVerify(PDO $pdo): void
|
||||
$userId = $tokenData['user_id'];
|
||||
|
||||
$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
|
||||
LEFT JOIN roles r ON u.role_id = r.id
|
||||
WHERE u.id = ? AND u.totp_enabled = 1
|
||||
@@ -355,7 +359,8 @@ function verifyLoginToken(PDO $pdo, string $token): ?array
|
||||
$hashedToken = hash('sha256', $token);
|
||||
|
||||
$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()
|
||||
');
|
||||
$stmt->execute([$hashedToken]);
|
||||
|
||||
@@ -37,7 +37,10 @@ function handleGetCurrent(PDO $pdo, int $userId): void
|
||||
$endDate = date('Y-m-t', strtotime($startDate));
|
||||
|
||||
$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
|
||||
FROM trips t
|
||||
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));
|
||||
|
||||
$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
|
||||
FROM trips t
|
||||
JOIN vehicles v ON t.vehicle_id = v.id
|
||||
@@ -173,7 +179,10 @@ function handleGetAdmin(PDO $pdo): void
|
||||
}
|
||||
|
||||
$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
|
||||
FROM trips t
|
||||
JOIN vehicles v ON t.vehicle_id = v.id
|
||||
@@ -239,7 +248,10 @@ function handleGetAdmin(PDO $pdo): void
|
||||
function handleGetVehicles(PDO $pdo): void
|
||||
{
|
||||
$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
|
||||
FROM vehicles v
|
||||
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
|
||||
{
|
||||
$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]);
|
||||
$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
|
||||
{
|
||||
$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]);
|
||||
$trip = $stmt->fetch();
|
||||
|
||||
@@ -497,7 +517,9 @@ function handleDeleteVehicle(PDO $pdo, int $id): void
|
||||
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]);
|
||||
$vehicle = $stmt->fetch();
|
||||
|
||||
@@ -573,7 +595,10 @@ function handleGetPrint(PDO $pdo): void
|
||||
}
|
||||
|
||||
$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
|
||||
FROM trips t
|
||||
JOIN vehicles v ON t.vehicle_id = v.id
|
||||
|
||||
@@ -142,7 +142,11 @@ function handleCreateUser(PDO $pdo, array $authData): void
|
||||
function handleUpdateUser(PDO $pdo, int $userId, int $currentUserId, array $authData): void
|
||||
{
|
||||
// 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]);
|
||||
$existingUser = $stmt->fetch();
|
||||
|
||||
|
||||
@@ -43,7 +43,14 @@ $lang = in_array($_GET['lang'] ?? '', ['cs', 'en']) ? $_GET['lang'] : 'cs';
|
||||
try {
|
||||
$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]);
|
||||
$invoice = $stmt->fetch();
|
||||
if (!$invoice) {
|
||||
@@ -52,20 +59,32 @@ try {
|
||||
}
|
||||
|
||||
// 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]);
|
||||
$items = $stmt->fetchAll();
|
||||
|
||||
// Zakaznik
|
||||
$customer = null;
|
||||
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']]);
|
||||
$customer = $stmt->fetch();
|
||||
}
|
||||
|
||||
// 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();
|
||||
|
||||
// Logo
|
||||
|
||||
@@ -19,6 +19,7 @@ require_once dirname(__DIR__) . '/config.php';
|
||||
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
||||
require_once dirname(__DIR__) . '/includes/AuditLog.php';
|
||||
require_once dirname(__DIR__) . '/includes/CnbRates.php';
|
||||
require_once dirname(__DIR__) . '/includes/PaginationHelper.php';
|
||||
require_once __DIR__ . '/handlers/invoices-handlers.php';
|
||||
|
||||
setCorsHeaders();
|
||||
@@ -36,6 +37,11 @@ $id = isset($_GET['id']) ? (int) $_GET['id'] : null;
|
||||
try {
|
||||
$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) {
|
||||
case 'GET':
|
||||
requirePermission($authData, 'invoices.view');
|
||||
|
||||
@@ -38,7 +38,12 @@ if (!$id) {
|
||||
try {
|
||||
$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]);
|
||||
$quotation = $stmt->fetch();
|
||||
if (!$quotation) {
|
||||
@@ -48,20 +53,36 @@ try {
|
||||
|
||||
$customer = null;
|
||||
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']]);
|
||||
$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]);
|
||||
$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]);
|
||||
$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();
|
||||
|
||||
$logoBase64 = '';
|
||||
|
||||
@@ -18,6 +18,7 @@ declare(strict_types=1);
|
||||
require_once dirname(__DIR__) . '/config.php';
|
||||
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
||||
require_once dirname(__DIR__) . '/includes/AuditLog.php';
|
||||
require_once dirname(__DIR__) . '/includes/PaginationHelper.php';
|
||||
require_once __DIR__ . '/handlers/offers-handlers.php';
|
||||
|
||||
setCorsHeaders();
|
||||
|
||||
@@ -15,6 +15,7 @@ declare(strict_types=1);
|
||||
require_once dirname(__DIR__) . '/config.php';
|
||||
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
||||
require_once dirname(__DIR__) . '/includes/AuditLog.php';
|
||||
require_once dirname(__DIR__) . '/includes/PaginationHelper.php';
|
||||
require_once __DIR__ . '/handlers/orders-handlers.php';
|
||||
|
||||
setCorsHeaders();
|
||||
|
||||
@@ -35,7 +35,11 @@ try {
|
||||
$userId = $authData['user_id'];
|
||||
|
||||
// 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]);
|
||||
$existingUser = $stmt->fetch();
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ declare(strict_types=1);
|
||||
require_once dirname(__DIR__) . '/config.php';
|
||||
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
||||
require_once dirname(__DIR__) . '/includes/AuditLog.php';
|
||||
require_once dirname(__DIR__) . '/includes/PaginationHelper.php';
|
||||
require_once __DIR__ . '/handlers/projects-handlers.php';
|
||||
|
||||
setCorsHeaders();
|
||||
|
||||
58
api/cleanup.php
Normal file
58
api/cleanup.php
Normal 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";
|
||||
}
|
||||
@@ -17,7 +17,10 @@ function handleGetAdmin(PDO $pdo): void
|
||||
$endDate = date('Y-m-t', strtotime($startDate));
|
||||
|
||||
$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
|
||||
JOIN users u ON a.user_id = u.id
|
||||
WHERE a.shift_date BETWEEN ? AND ?
|
||||
@@ -112,7 +115,11 @@ function handleGetWorkFund(PDO $pdo): void
|
||||
$startDate = sprintf('%04d-01-01', $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]);
|
||||
$allRecords = $stmt->fetchAll();
|
||||
|
||||
@@ -206,7 +213,13 @@ function handleGetWorkFund(PDO $pdo): void
|
||||
function handleGetLocation(PDO $pdo, int $recordId): void
|
||||
{
|
||||
$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
|
||||
JOIN users u ON a.user_id = u.id
|
||||
WHERE a.id = ?
|
||||
@@ -467,7 +480,11 @@ function handleUpdateBalance(PDO $pdo): 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]);
|
||||
$record = $stmt->fetch();
|
||||
|
||||
@@ -593,7 +610,10 @@ function handleUpdateAttendance(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]);
|
||||
$record = $stmt->fetch();
|
||||
|
||||
@@ -920,7 +940,10 @@ function handleGetPrint(PDO $pdo): void
|
||||
$users = $stmt->fetchAll();
|
||||
|
||||
$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
|
||||
JOIN users u ON a.user_id = u.id
|
||||
WHERE a.shift_date BETWEEN ? AND ?
|
||||
|
||||
@@ -217,7 +217,9 @@ function enrichRecordsWithProjectLogs(PDO $pdo, array &$records): void
|
||||
if (!empty($recordIds)) {
|
||||
$placeholders = implode(',', array_fill(0, count($recordIds), '?'));
|
||||
$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);
|
||||
foreach ($stmt->fetchAll() as $log) {
|
||||
|
||||
@@ -460,7 +460,9 @@ class AuditLog
|
||||
|
||||
// Get logs
|
||||
$sql = "
|
||||
SELECT *
|
||||
SELECT id, user_id, username, user_ip, action,
|
||||
entity_type, entity_id, description,
|
||||
old_values, new_values, created_at
|
||||
FROM audit_logs
|
||||
$whereClause
|
||||
ORDER BY created_at DESC
|
||||
@@ -503,7 +505,9 @@ class AuditLog
|
||||
$pdo = db();
|
||||
|
||||
$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
|
||||
WHERE user_id = ?
|
||||
ORDER BY created_at DESC
|
||||
@@ -531,7 +535,9 @@ class AuditLog
|
||||
$pdo = db();
|
||||
|
||||
$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
|
||||
WHERE entity_type = ? AND entity_id = ?
|
||||
ORDER BY created_at DESC
|
||||
|
||||
@@ -245,8 +245,11 @@ class JWTAuth
|
||||
|
||||
// First check if token exists (regardless of expiry)
|
||||
$stmt = $pdo->prepare('
|
||||
SELECT rt.*, 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
|
||||
SELECT rt.id, rt.user_id, rt.token_hash, rt.expires_at,
|
||||
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
|
||||
JOIN users u ON rt.user_id = u.id
|
||||
LEFT JOIN roles r ON u.role_id = r.id
|
||||
|
||||
84
api/includes/PaginationHelper.php
Normal file
84
api/includes/PaginationHelper.php
Normal 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
139
api/includes/Validator.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@
|
||||
"require": {
|
||||
"php": ">=8.1",
|
||||
"firebase/php-jwt": "^6.11",
|
||||
"tecnickcom/tcpdf": "^6.7",
|
||||
"robthree/twofactorauth": "^3.0",
|
||||
"chillerlan/php-qrcode": "^5.0"
|
||||
},
|
||||
|
||||
20
migrations/001_add_indexes.sql
Normal file
20
migrations/001_add_indexes.sql
Normal 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);
|
||||
1
migrations/002_audit_permission.sql
Normal file
1
migrations/002_audit_permission.sql
Normal file
@@ -0,0 +1 @@
|
||||
INSERT INTO permissions (name, description) VALUES ('settings.audit', 'Zobrazení audit logu') ON DUPLICATE KEY UPDATE description = VALUES(description);
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { AlertProvider } from './context/AlertContext'
|
||||
import ErrorBoundary from './components/ErrorBoundary'
|
||||
@@ -45,6 +45,8 @@ const Invoices = lazy(() => import('./pages/Invoices'))
|
||||
const InvoiceCreate = lazy(() => import('./pages/InvoiceCreate'))
|
||||
const InvoiceDetail = lazy(() => import('./pages/InvoiceDetail'))
|
||||
const Settings = lazy(() => import('./pages/Settings'))
|
||||
const AuditLog = lazy(() => import('./pages/AuditLog'))
|
||||
const NotFound = lazy(() => import('./pages/NotFound'))
|
||||
|
||||
export default function AdminApp() {
|
||||
return (
|
||||
@@ -85,8 +87,9 @@ export default function AdminApp() {
|
||||
<Route path="invoices/new" element={<InvoiceCreate />} />
|
||||
<Route path="invoices/:id" element={<InvoiceDetail />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
<Route path="audit-log" element={<AuditLog />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
|
||||
@@ -2332,6 +2332,112 @@ img {
|
||||
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 */
|
||||
.admin-kbd {
|
||||
display: inline-block;
|
||||
|
||||
146
src/admin/components/AttendanceShiftTable.jsx
Normal file
146
src/admin/components/AttendanceShiftTable.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import useModalLock from '../hooks/useModalLock'
|
||||
import useFocusTrap from '../hooks/useFocusTrap'
|
||||
|
||||
export default function ConfirmModal({
|
||||
isOpen,
|
||||
@@ -14,6 +15,7 @@ export default function ConfirmModal({
|
||||
loading = false
|
||||
}) {
|
||||
useModalLock(isOpen)
|
||||
const trapRef = useFocusTrap(isOpen)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
@@ -70,6 +72,7 @@ export default function ConfirmModal({
|
||||
>
|
||||
<div className="admin-modal-backdrop" onClick={onClose} />
|
||||
<motion.div
|
||||
ref={trapRef}
|
||||
className="admin-modal admin-confirm-modal"
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Component } from 'react'
|
||||
|
||||
export default class ErrorBoundary extends Component {
|
||||
state = { hasError: false }
|
||||
state = { hasError: false, error: null }
|
||||
|
||||
static getDerivedStateFromError() {
|
||||
return { hasError: true }
|
||||
static getDerivedStateFromError(error) {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
|
||||
componentDidCatch(error, info) {
|
||||
@@ -16,29 +16,32 @@ export default class ErrorBoundary extends Component {
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '50vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '1rem',
|
||||
color: 'var(--text-secondary, #888)'
|
||||
}}>
|
||||
<p>Něco se pokazilo při načítání stránky.</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
style={{
|
||||
padding: '0.5rem 1.5rem',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--border-color, #333)',
|
||||
background: 'var(--bg-secondary, #1a1a1a)',
|
||||
color: 'var(--text-primary, #fff)',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
Načíst znovu
|
||||
</button>
|
||||
<div className="admin-empty-state" style={{ minHeight: '60vh', justifyContent: 'center' }}>
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
<p style={{ marginBottom: '0.5rem' }}>Něco se pokazilo při načítání stránky.</p>
|
||||
{import.meta.env.DEV && this.state.error && (
|
||||
<pre className="admin-error-stack">
|
||||
{this.state.error.message}
|
||||
{this.state.error.stack && `\n${this.state.error.stack}`}
|
||||
</pre>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.5rem' }}>
|
||||
<a href="/" className="admin-btn admin-btn-secondary">
|
||||
Zpět na Dashboard
|
||||
</a>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="admin-btn admin-btn-primary"
|
||||
>
|
||||
Načíst znovu
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
23
src/admin/components/FormField.jsx
Normal file
23
src/admin/components/FormField.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
90
src/admin/components/OfferCustomerPicker.jsx
Normal file
90
src/admin/components/OfferCustomerPicker.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
106
src/admin/components/Pagination.jsx
Normal file
106
src/admin/components/Pagination.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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" />
|
||||
</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>
|
||||
)
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
65
src/admin/components/dashboard/DashActivityFeed.jsx
Normal file
65
src/admin/components/dashboard/DashActivityFeed.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
33
src/admin/components/dashboard/DashAttendanceToday.jsx
Normal file
33
src/admin/components/dashboard/DashAttendanceToday.jsx
Normal 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 →</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>
|
||||
)
|
||||
}
|
||||
91
src/admin/components/dashboard/DashKpiCards.jsx
Normal file
91
src/admin/components/dashboard/DashKpiCards.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
310
src/admin/components/dashboard/DashProfile.jsx
Normal file
310
src/admin/components/dashboard/DashProfile.jsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
335
src/admin/components/dashboard/DashQuickActions.jsx
Normal file
335
src/admin/components/dashboard/DashQuickActions.jsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
199
src/admin/components/dashboard/DashSessions.jsx
Normal file
199
src/admin/components/dashboard/DashSessions.jsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
627
src/admin/hooks/useAttendanceAdmin.js
Normal file
627
src/admin/hooks/useAttendanceAdmin.js
Normal 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>`
|
||||
}
|
||||
16
src/admin/hooks/useDebounce.js
Normal file
16
src/admin/hooks/useDebounce.js
Normal 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
|
||||
}
|
||||
53
src/admin/hooks/useFocusTrap.js
Normal file
53
src/admin/hooks/useFocusTrap.js
Normal 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
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useAlert } from '../context/AlertContext'
|
||||
import apiFetch from '../utils/api'
|
||||
import useDebounce from './useDebounce'
|
||||
|
||||
const API_BASE = '/api/admin'
|
||||
|
||||
@@ -13,50 +14,59 @@ const API_BASE = '/api/admin'
|
||||
* @param {string} opts.search - Hledany text
|
||||
* @param {string} opts.sort - Sloupec pro razeni
|
||||
* @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
|
||||
*/
|
||||
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 [items, setItems] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [pagination, setPagination] = useState(null)
|
||||
const abortRef = useRef(null)
|
||||
const extraParamsStr = extraParams ? JSON.stringify(extraParams) : ''
|
||||
const debouncedSearch = useDebounce(search, 300)
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
if (abortRef.current) abortRef.current.abort()
|
||||
if (abortRef.current) { abortRef.current.abort() }
|
||||
const controller = new AbortController()
|
||||
abortRef.current = controller
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (search) params.set('search', search)
|
||||
if (sort) params.set('sort', sort)
|
||||
if (order) params.set('order', order)
|
||||
if (debouncedSearch) { params.set('search', debouncedSearch) }
|
||||
if (sort) { params.set('sort', sort) }
|
||||
if (order) { params.set('order', order) }
|
||||
if (page) { params.set('page', page) }
|
||||
if (perPage) { params.set('per_page', perPage) }
|
||||
if (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 })
|
||||
if (response.status === 401) return
|
||||
if (response.status === 401) { return }
|
||||
const result = await response.json()
|
||||
if (result.success) {
|
||||
setItems(result.data[dataKey] || [])
|
||||
if (result.data.pagination) {
|
||||
setPagination(result.data.pagination)
|
||||
}
|
||||
} else {
|
||||
alert.error(result.error || errorMsg)
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.name === 'AbortError') return
|
||||
if (err.name === 'AbortError') { return }
|
||||
alert.error('Chyba připojení')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [alert, endpoint, dataKey, search, sort, order, extraParamsStr, errorMsg])
|
||||
}, [alert, endpoint, dataKey, debouncedSearch, sort, order, page, perPage, extraParamsStr, errorMsg])
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
return () => { if (abortRef.current) abortRef.current.abort() }
|
||||
return () => { if (abortRef.current) { abortRef.current.abort() } }
|
||||
}, [fetchData])
|
||||
|
||||
return { items, setItems, loading, refetch: fetchData }
|
||||
return { items, setItems, loading, pagination, refetch: fetchData }
|
||||
}
|
||||
|
||||
437
src/admin/hooks/useOfferForm.js
Normal file
437
src/admin/hooks/useOfferForm.js
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,15 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import DOMPurify from 'dompurify'
|
||||
import { useAlert } from '../context/AlertContext'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import Forbidden from '../components/Forbidden'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import ConfirmModal from '../components/ConfirmModal'
|
||||
import AdminDatePicker from '../components/AdminDatePicker'
|
||||
import BulkAttendanceModal from '../components/BulkAttendanceModal'
|
||||
import ShiftFormModal from '../components/ShiftFormModal'
|
||||
import AttendanceShiftTable from '../components/AttendanceShiftTable'
|
||||
import useModalLock from '../hooks/useModalLock'
|
||||
import apiFetch from '../utils/api'
|
||||
import {
|
||||
formatDate, formatDatetime, formatTime,
|
||||
calculateWorkMinutes, formatMinutes,
|
||||
getLeaveTypeName, getLeaveTypeBadgeClass,
|
||||
getDatePart, getTimePart,
|
||||
calcProjectMinutesTotal, calcFormWorkMinutes,
|
||||
formatTimeOrDatetimePrint, calculateWorkMinutesPrint
|
||||
} from '../utils/attendanceHelpers'
|
||||
|
||||
const API_BASE = '/api/admin'
|
||||
import useAttendanceAdmin from '../hooks/useAttendanceAdmin'
|
||||
import { formatMinutes } from '../utils/attendanceHelpers'
|
||||
|
||||
function getFundBarBackground(data) {
|
||||
if (data.overtime > 0) return 'linear-gradient(135deg, var(--warning), #d97706)'
|
||||
@@ -28,160 +17,29 @@ function getFundBarBackground(data) {
|
||||
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() {
|
||||
const alert = useAlert()
|
||||
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 [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([])
|
||||
|
||||
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])
|
||||
const {
|
||||
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,
|
||||
openCreateModal, handleCreateShiftDateChange, handleCreateSubmit,
|
||||
openBulkModal, toggleBulkUser, toggleAllBulkUsers, handleBulkSubmit,
|
||||
openEditModal, handleEditSubmit,
|
||||
handleDelete, handlePrint
|
||||
} = useAttendanceAdmin({ alert })
|
||||
|
||||
useModalLock(showBulkModal)
|
||||
useModalLock(showEditModal)
|
||||
@@ -189,388 +47,6 @@ export default function AttendanceAdmin() {
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<motion.div
|
||||
@@ -761,98 +237,12 @@ export default function AttendanceAdmin() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!loading && data.records.length === 0 && (
|
||||
<div className="admin-empty-state">
|
||||
<p>Za tento měsíc nejsou žádné záznamy.</p>
|
||||
</div>
|
||||
)}
|
||||
{!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>
|
||||
{!loading && (
|
||||
<AttendanceShiftTable
|
||||
records={data.records}
|
||||
onEdit={openEditModal}
|
||||
onDelete={(record) => setDeleteConfirm({ show: true, record })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
@@ -906,131 +296,6 @@ export default function AttendanceAdmin() {
|
||||
confirmText="Smazat"
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
273
src/admin/pages/AuditLog.jsx
Normal file
273
src/admin/pages/AuditLog.jsx
Normal 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
@@ -11,6 +11,7 @@ import { formatCurrency, formatDate, czechPlural } from '../utils/formatters'
|
||||
import SortIcon from '../components/SortIcon'
|
||||
import useTableSort from '../hooks/useTableSort'
|
||||
import useListData from '../hooks/useListData'
|
||||
import Pagination from '../components/Pagination'
|
||||
|
||||
const ReceivedInvoices = lazy(() => import('./ReceivedInvoices'))
|
||||
const API_BASE = '/api/admin'
|
||||
@@ -65,6 +66,7 @@ export default function Invoices() {
|
||||
const [receivedUploadOpen, setReceivedUploadOpen] = useState(false)
|
||||
const { sort, order, handleSort, activeSort } = useTableSort('invoice_number')
|
||||
const [search, setSearch] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [statusFilter, setStatusFilter] = useState('')
|
||||
|
||||
const now = new Date()
|
||||
@@ -139,8 +141,8 @@ export default function Invoices() {
|
||||
setDraft(null)
|
||||
}
|
||||
|
||||
const { items: invoices, loading, refetch: fetchData } = useListData('invoices.php', {
|
||||
dataKey: 'invoices', search, sort, order,
|
||||
const { items: invoices, loading, pagination, refetch: fetchData } = useListData('invoices.php', {
|
||||
dataKey: 'invoices', search, sort, order, page,
|
||||
extraParams: statusFilter ? { status: statusFilter } : {},
|
||||
errorMsg: 'Nepodařilo se načíst faktury'
|
||||
})
|
||||
@@ -269,7 +271,7 @@ export default function Invoices() {
|
||||
<div>
|
||||
<h1 className="admin-page-title">Faktury</h1>
|
||||
<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>
|
||||
</div>
|
||||
{hasPermission('invoices.create') && (
|
||||
@@ -430,7 +432,7 @@ export default function Invoices() {
|
||||
<button
|
||||
key={f.value}
|
||||
className={`offers-tab ${statusFilter === f.value ? 'active' : ''}`}
|
||||
onClick={() => setStatusFilter(f.value)}
|
||||
onClick={() => { setStatusFilter(f.value); setPage(1) }}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
@@ -450,7 +452,7 @@ export default function Invoices() {
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(1) }}
|
||||
className="admin-form-input"
|
||||
placeholder="Hledat podle čísla faktury, zákazníka nebo IČ..."
|
||||
/>
|
||||
@@ -622,6 +624,7 @@ export default function Invoices() {
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<Pagination pagination={pagination} onPageChange={setPage} />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
|
||||
30
src/admin/pages/NotFound.jsx
Normal file
30
src/admin/pages/NotFound.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useAlert } from '../context/AlertContext'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||
@@ -9,28 +9,12 @@ import Forbidden from '../components/Forbidden'
|
||||
import AdminDatePicker from '../components/AdminDatePicker'
|
||||
import OfferItemsSection from '../components/OfferItemsSection'
|
||||
import OfferScopeSection from '../components/OfferScopeSection'
|
||||
import OfferCustomerPicker from '../components/OfferCustomerPicker'
|
||||
import useModalLock from '../hooks/useModalLock'
|
||||
import useOfferForm from '../hooks/useOfferForm'
|
||||
import apiFetch from '../utils/api'
|
||||
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() {
|
||||
const { id } = useParams()
|
||||
const isEdit = Boolean(id)
|
||||
@@ -38,361 +22,35 @@ export default function OfferDetail() {
|
||||
const { hasPermission } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [loading, setLoading] = useState(isEdit)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [errors, setErrors] = useState({})
|
||||
const [customers, setCustomers] = useState([])
|
||||
const [customerSearch, setCustomerSearch] = useState('')
|
||||
const [showCustomerDropdown, setShowCustomerDropdown] = useState(false)
|
||||
const [itemTemplates, setItemTemplates] = useState([])
|
||||
const [scopeTemplates, setScopeTemplates] = useState([])
|
||||
const {
|
||||
loading, saving, errors, setErrors,
|
||||
form, updateForm, items, setItems, sections,
|
||||
customers, itemTemplates, scopeTemplates,
|
||||
orderInfo, offerStatus, setOfferStatus,
|
||||
totals, draftSavedAtLabel,
|
||||
selectCustomer, clearCustomer,
|
||||
updateItem, addItem, removeItem, addItemFromTemplate,
|
||||
addSection, removeSection, updateSection, moveSection,
|
||||
loadScopeTemplate, handleSave
|
||||
} = useOfferForm({ id, isEdit, alert, navigate })
|
||||
|
||||
const [showItemTemplateMenu, setShowItemTemplateMenu] = 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 [deleting, setDeleting] = useState(false)
|
||||
const [orderInfo, setOrderInfo] = useState(null)
|
||||
const [creatingOrder, setCreatingOrder] = useState(false)
|
||||
const [showOrderModal, setShowOrderModal] = useState(false)
|
||||
const [offerStatus, setOfferStatus] = useState('active')
|
||||
const [invalidateConfirm, setInvalidateConfirm] = useState(false)
|
||||
const [invalidatingOffer, setInvalidatingOffer] = useState(false)
|
||||
useModalLock(showOrderModal)
|
||||
const [customerOrderNumber, setCustomerOrderNumber] = useState('')
|
||||
const [orderAttachment, setOrderAttachment] = useState(null)
|
||||
const [pdfLoading, setPdfLoading] = useState(false)
|
||||
|
||||
const DRAFT_KEY = 'boha_offer_draft'
|
||||
const [draftSavedAt, setDraftSavedAt] = useState(null)
|
||||
const draftDataRef = useRef({ form, items, sections })
|
||||
const draftRestoredRef = useRef(false)
|
||||
useModalLock(showOrderModal)
|
||||
|
||||
// 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(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 isInvalidated = offerStatus === 'invalidated'
|
||||
const isExpiredNotInvalidated = isEdit && !isInvalidated && !orderInfo && form.valid_until && new Date(form.valid_until) < new Date(new Date().toDateString())
|
||||
|
||||
const handleCreateOrder = async () => {
|
||||
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 () => {
|
||||
setInvalidatingOffer(true)
|
||||
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 () => {
|
||||
setDeleting(true)
|
||||
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) {
|
||||
return (
|
||||
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}>
|
||||
@@ -755,54 +340,15 @@ export default function OfferDetail() {
|
||||
readOnly={isInvalidated}
|
||||
/>
|
||||
</div>
|
||||
<div className={`admin-form-group${errors.customer_id ? ' has-error' : ''}`}>
|
||||
<label className="admin-form-label required">Zákazník</label>
|
||||
{form.customer_id && (
|
||||
<div className="offers-customer-selected">
|
||||
<span>{form.customer_name}</span>
|
||||
{!isInvalidated && (
|
||||
<button type="button" onClick={clearCustomer} 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>
|
||||
)}
|
||||
{!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>
|
||||
<OfferCustomerPicker
|
||||
customers={customers}
|
||||
customerId={form.customer_id}
|
||||
customerName={form.customer_name}
|
||||
onSelect={selectCustomer}
|
||||
onClear={clearCustomer}
|
||||
error={errors.customer_id}
|
||||
readOnly={isInvalidated}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-row">
|
||||
@@ -849,10 +395,10 @@ export default function OfferDetail() {
|
||||
className="admin-form-select"
|
||||
disabled={isInvalidated}
|
||||
>
|
||||
<option value="EUR">EUR (€)</option>
|
||||
<option value="EUR">EUR (€)</option>
|
||||
<option value="USD">USD ($)</option>
|
||||
<option value="CZK">CZK (Kč)</option>
|
||||
<option value="GBP">GBP (£)</option>
|
||||
<option value="GBP">GBP (£)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
|
||||
@@ -12,6 +12,7 @@ import SortIcon from '../components/SortIcon'
|
||||
import useTableSort from '../hooks/useTableSort'
|
||||
import useListData from '../hooks/useListData'
|
||||
import useModalLock from '../hooks/useModalLock'
|
||||
import Pagination from '../components/Pagination'
|
||||
const API_BASE = '/api/admin'
|
||||
const DRAFT_KEY = 'boha_offer_draft'
|
||||
|
||||
@@ -22,6 +23,7 @@ export default function Offers() {
|
||||
|
||||
const { sort, order, handleSort, activeSort } = useTableSort('quotation_number')
|
||||
const [search, setSearch] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
const [deleteConfirm, setDeleteConfirm] = useState({ show: false, quotation: null })
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
@@ -36,8 +38,8 @@ export default function Offers() {
|
||||
const [orderAttachment, setOrderAttachment] = useState(null)
|
||||
const [draft, setDraft] = useState(null)
|
||||
|
||||
const { items: quotations, loading, refetch: fetchData } = useListData('offers.php', {
|
||||
dataKey: 'quotations', search, sort, order,
|
||||
const { items: quotations, loading, pagination, refetch: fetchData } = useListData('offers.php', {
|
||||
dataKey: 'quotations', search, sort, order, page,
|
||||
errorMsg: 'Nepodařilo se načíst nabídky'
|
||||
})
|
||||
|
||||
@@ -232,7 +234,7 @@ export default function Offers() {
|
||||
<div>
|
||||
<h1 className="admin-page-title">Nabídky</h1>
|
||||
<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>
|
||||
</div>
|
||||
<div className="admin-page-actions">
|
||||
@@ -268,7 +270,7 @@ export default function Offers() {
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(1) }}
|
||||
className="admin-form-input"
|
||||
placeholder="Hledat podle čísla, projektu nebo zákazníka..."
|
||||
/>
|
||||
@@ -509,6 +511,7 @@ export default function Offers() {
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<Pagination pagination={pagination} onPageChange={setPage} />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { formatCurrency, formatDate, czechPlural } from '../utils/formatters'
|
||||
import SortIcon from '../components/SortIcon'
|
||||
import useTableSort from '../hooks/useTableSort'
|
||||
import useListData from '../hooks/useListData'
|
||||
import Pagination from '../components/Pagination'
|
||||
const API_BASE = '/api/admin'
|
||||
|
||||
const STATUS_LABELS = {
|
||||
@@ -33,12 +34,13 @@ export default function Orders() {
|
||||
|
||||
const { sort, order, handleSort, activeSort } = useTableSort('order_number')
|
||||
const [search, setSearch] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
const [deleteConfirm, setDeleteConfirm] = useState({ show: false, order: null })
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
|
||||
const { items: orders, loading, refetch: fetchData } = useListData('orders.php', {
|
||||
dataKey: 'orders', search, sort, order,
|
||||
const { items: orders, loading, pagination, refetch: fetchData } = useListData('orders.php', {
|
||||
dataKey: 'orders', search, sort, order, page,
|
||||
errorMsg: 'Nepodařilo se načíst objednávky'
|
||||
})
|
||||
|
||||
@@ -137,7 +139,7 @@ export default function Orders() {
|
||||
<div>
|
||||
<h1 className="admin-page-title">Objednávky</h1>
|
||||
<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>
|
||||
</div>
|
||||
</motion.div>
|
||||
@@ -153,7 +155,7 @@ export default function Orders() {
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(1) }}
|
||||
className="admin-form-input"
|
||||
placeholder="Hledat podle čísla, nabídky, projektu nebo zákazníka..."
|
||||
/>
|
||||
@@ -264,6 +266,7 @@ export default function Orders() {
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<Pagination pagination={pagination} onPageChange={setPage} />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { formatDate, czechPlural } from '../utils/formatters'
|
||||
import SortIcon from '../components/SortIcon'
|
||||
import useTableSort from '../hooks/useTableSort'
|
||||
import useListData from '../hooks/useListData'
|
||||
import Pagination from '../components/Pagination'
|
||||
const API_BASE = '/api/admin'
|
||||
|
||||
const STATUS_LABELS = {
|
||||
@@ -31,11 +32,12 @@ export default function Projects() {
|
||||
|
||||
const { sort, order, handleSort, activeSort } = useTableSort('project_number')
|
||||
const [search, setSearch] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [deletingId, setDeletingId] = useState(null)
|
||||
const [deleteTarget, setDeleteTarget] = useState(null)
|
||||
|
||||
const { items: projects, setItems: setProjects, loading } = useListData('projects.php', {
|
||||
dataKey: 'projects', search, sort, order,
|
||||
const { items: projects, setItems: setProjects, loading, pagination } = useListData('projects.php', {
|
||||
dataKey: 'projects', search, sort, order, page,
|
||||
errorMsg: 'Nepodařilo se načíst projekty'
|
||||
})
|
||||
|
||||
@@ -132,7 +134,7 @@ export default function Projects() {
|
||||
<div>
|
||||
<h1 className="admin-page-title">Projekty</h1>
|
||||
<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>
|
||||
</div>
|
||||
{hasPermission('projects.create') && (
|
||||
@@ -157,7 +159,7 @@ export default function Projects() {
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(1) }}
|
||||
className="admin-form-input"
|
||||
placeholder="Hledat podle čísla, názvu nebo zákazníka..."
|
||||
/>
|
||||
@@ -258,6 +260,7 @@ export default function Projects() {
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<Pagination pagination={pagination} onPageChange={setPage} />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import useModalLock from '../hooks/useModalLock'
|
||||
|
||||
import { formatKm } from '../utils/formatters'
|
||||
import apiFetch from '../utils/api'
|
||||
import FormField from '../components/FormField'
|
||||
const API_BASE = '/api/admin'
|
||||
|
||||
export default function Vehicles() {
|
||||
@@ -338,8 +339,7 @@ export default function Vehicles() {
|
||||
<div className="admin-modal-body">
|
||||
<div className="admin-form">
|
||||
<div className="admin-form-row">
|
||||
<div className={`admin-form-group${errors.spz ? ' has-error' : ''}`}>
|
||||
<label className="admin-form-label required">SPZ</label>
|
||||
<FormField label="SPZ" error={errors.spz} required>
|
||||
<input
|
||||
type="text"
|
||||
value={form.spz}
|
||||
@@ -349,12 +349,11 @@ export default function Vehicles() {
|
||||
}}
|
||||
className="admin-form-input"
|
||||
placeholder="1AB 2345"
|
||||
aria-invalid={!!errors.spz}
|
||||
/>
|
||||
{errors.spz && <span className="admin-form-error">{errors.spz}</span>}
|
||||
</div>
|
||||
</FormField>
|
||||
|
||||
<div className={`admin-form-group${errors.name ? ' has-error' : ''}`}>
|
||||
<label className="admin-form-label required">Název</label>
|
||||
<FormField label="Název" error={errors.name} required>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
@@ -364,14 +363,13 @@ export default function Vehicles() {
|
||||
}}
|
||||
className="admin-form-input"
|
||||
placeholder="Služební #1"
|
||||
aria-invalid={!!errors.name}
|
||||
/>
|
||||
{errors.name && <span className="admin-form-error">{errors.name}</span>}
|
||||
</div>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Značka</label>
|
||||
<FormField label="Značka">
|
||||
<input
|
||||
type="text"
|
||||
value={form.brand}
|
||||
@@ -379,10 +377,9 @@ export default function Vehicles() {
|
||||
className="admin-form-input"
|
||||
placeholder="Škoda"
|
||||
/>
|
||||
</div>
|
||||
</FormField>
|
||||
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Model</label>
|
||||
<FormField label="Model">
|
||||
<input
|
||||
type="text"
|
||||
value={form.model}
|
||||
@@ -390,7 +387,7 @@ export default function Vehicles() {
|
||||
className="admin-form-input"
|
||||
placeholder="Octavia Combi"
|
||||
/>
|
||||
</div>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-group">
|
||||
|
||||
85
src/admin/utils/dashboardHelpers.js
Normal file
85
src/admin/utils/dashboardHelpers.js
Normal 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'
|
||||
})
|
||||
}
|
||||
@@ -41,14 +41,21 @@ import { defineConfig } from 'vite'
|
||||
|
||||
try {
|
||||
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)
|
||||
execSync('composer install --quiet', { cwd: resolve(__dirname) })
|
||||
console.log('✓ Vendor folder copied to dist/vendor (production only)')
|
||||
try {
|
||||
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) {
|
||||
console.error('Error copying vendor folder:', err)
|
||||
execSync('composer install --quiet', { cwd: resolve(__dirname) })
|
||||
}
|
||||
|
||||
console.log('✓ Build complete!')
|
||||
|
||||
Reference in New Issue
Block a user