feat: filemanager s NAS pro projekty

- NasFileManager.php - filesystem helper (browse, upload, download, delete, rename, mkdir)
- project-files.php API - CRUD operace nad soubory projektu
- ProjectFileManager.jsx - React komponenta v detailu projektu
- Automaticke vytvoreni slozky pri vytvoreni projektu (rucne i z objednavky)
- Prejmenovani slozky pri zmene nazvu projektu
- Checkbox "Smazat i soubory na disku" pri mazani projektu/objednavky
- Path traversal ochrana, MIME validace, blocklist nebezpecnych typu
- Bily spinner v primary tlacitkach, ConfirmModal message jako div
- Case-insensitive rename fix pro Windows filesystem

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 13:06:34 +01:00
parent 9e3c95e576
commit 45fd930f76
69 changed files with 2776 additions and 71 deletions

4
.gitignore vendored
View File

@@ -5,7 +5,7 @@
# Dependencies # Dependencies
node_modules/ node_modules/
vendor/ # vendor/
example_design/ example_design/
sql/ sql/
@@ -15,6 +15,8 @@ sql/
# IDE # IDE
.vscode/ .vscode/
.idea/ .idea/
.claude
CLAUDE.md
# OS # OS
.DS_Store .DS_Store

View File

@@ -41,6 +41,32 @@ Aplikace běží na http://localhost:3000
--- ---
## Build a Git workflow
- `dist/` JE v gitu (neni v .gitignore) - slouzi pro deploy na produkci
- Produkce se aktualizuje pres `git pull` z tohoto repa
- VZDY spust `npm run build` pred pushnutim
- NIKDY necommituj bez uspesneho buildu
- Kazdy commit MUSI obsahovat aktualni dist/
### Postup pred kazdym pushem:
```bash
# 1. Lint kontroly
npx eslint src/
vendor/bin/phpcs
# 2. Build
npm run build
# 3. Commit vse vcetne dist/
git add .
git commit -m "typ: popis zmeny"
git push
```
---
## Autonomní režim ## Autonomní režim
Když běžíš s `--auto`, dodržuj tento postup: Když běžíš s `--auto`, dodržuj tento postup:
@@ -62,7 +88,7 @@ Když běžíš s `--auto`, dodržuj tento postup:
- `npx eslint src/` - 0 errors - `npx eslint src/` - 0 errors
- `vendor/bin/phpcs` - 0 errors - `vendor/bin/phpcs` - 0 errors
- `npm run build` - musí projít - `npm run build` - musí projít
2. Commitni a pushni: 2. Commitni a pushni (VCETNE dist/):
- `git add .` - `git add .`
- `git commit -m "typ: stručný popis změn"` - `git commit -m "typ: stručný popis změn"`
- `git push` - `git push`
@@ -153,13 +179,12 @@ Když běžíš s `--auto`, dodržuj tento postup:
--- ---
## Kontroly před buildem ## Migrace (SQL změny)
Vždy spusť před `npm run build`: - Každá SQL změna (INSERT, ALTER TABLE, CREATE TABLE) MUSÍ mít migrační soubor v `migrations/`
- Formát názvu: `NNN_popis.sql` (číslování navazuje na poslední existující)
1. `npx eslint src/` - 0 errors, 0 warnings - Migrace commitovat přes `git add -f migrations/` (složka je v .gitignore)
2. `vendor/bin/phpcs` - 0 errors - Migraci vytvořit VŽDY - i když se SQL spustí ručně přes PHP nebo phpMyAdmin
3. Build musí projít bez chyb
--- ---
@@ -169,6 +194,7 @@ Vždy spusť před `npm run build`:
- NIKDY nepoužívej SSH, SCP, rsync na jakýkoli vzdálený server - NIKDY nepoužívej SSH, SCP, rsync na jakýkoli vzdálený server
- NIKDY neměň databázi na vzdáleném serveru - NIKDY neměň databázi na vzdáleném serveru
- NIKDY nespouštěj Chrome bez výslovné žádosti uživatele - NIKDY nespouštěj Chrome bez výslovné žádosti uživatele
- NIKDY necommituj bez úspěšného npm run build
- Žádné TODO/FIXME v kódu - Žádné TODO/FIXME v kódu
- Žádné `console.log` v kódu (console.error jen s `import.meta.env.DEV` guardem) - Žádné `console.log` v kódu (console.error jen s `import.meta.env.DEV` guardem)
- Funkce max 50 řádků - u React komponent se počítá logika, ne JSX template - Funkce max 50 řádků - u React komponent se počítá logika, ne JSX template

View File

@@ -128,6 +128,11 @@ function handleGetDetail(PDO $pdo, int $id): void
$stmt->execute([$id]); $stmt->execute([$id]);
$order['project'] = $stmt->fetch() ?: null; $order['project'] = $stmt->fetch() ?: null;
if ($order['project']) {
$fm = new NasFileManager();
$order['project']['has_nas_folder'] = $fm->projectFolderExists($order['project']['project_number']);
}
// Get linked invoice // Get linked invoice
$stmt = $pdo->prepare('SELECT id, invoice_number, status FROM invoices WHERE order_id = ? LIMIT 1'); $stmt = $pdo->prepare('SELECT id, invoice_number, status FROM invoices WHERE order_id = ? LIMIT 1');
$stmt->execute([$id]); $stmt->execute([$id]);
@@ -352,6 +357,10 @@ function handleCreateOrder(PDO $pdo): void
$pdo->commit(); $pdo->commit();
$pdo->query("SELECT RELEASE_LOCK('boha_order_number')"); $pdo->query("SELECT RELEASE_LOCK('boha_order_number')");
// Vytvorit slozku na NAS pro novy projekt
$fm = new NasFileManager();
$fm->createProjectFolder($orderNumber, $projectName);
AuditLog::logCreate('orders_order', $orderId, [ AuditLog::logCreate('orders_order', $orderId, [
'order_number' => $orderNumber, 'order_number' => $orderNumber,
'quotation_number' => $quotation['quotation_number'], 'quotation_number' => $quotation['quotation_number'],
@@ -482,6 +491,9 @@ function handleUpdateOrder(PDO $pdo, int $id): void
function handleDeleteOrder(PDO $pdo, int $id): void function handleDeleteOrder(PDO $pdo, int $id): void
{ {
$input = getJsonInput();
$deleteFiles = (bool) ($input['delete_files'] ?? false);
$stmt = $pdo->prepare( $stmt = $pdo->prepare(
'SELECT id, order_number, quotation_id FROM orders WHERE id = ?' 'SELECT id, order_number, quotation_id FROM orders WHERE id = ?'
); );
@@ -492,8 +504,21 @@ function handleDeleteOrder(PDO $pdo, int $id): void
errorResponse('Objednávka nebyla nalezena', 404); errorResponse('Objednávka nebyla nalezena', 404);
} }
// Nacist projekt pred smazanim (pro NAS slozku)
$stmt = $pdo->prepare('SELECT project_number FROM projects WHERE order_id = ?');
$stmt->execute([$id]);
$project = $stmt->fetch();
$pdo->beginTransaction(); $pdo->beginTransaction();
try { try {
// Delete project notes
if ($project) {
$stmt = $pdo->prepare(
'DELETE FROM project_notes WHERE project_id IN (SELECT id FROM projects WHERE order_id = ?)'
);
$stmt->execute([$id]);
}
// Delete project linked to this order // Delete project linked to this order
$stmt = $pdo->prepare('DELETE FROM projects WHERE order_id = ?'); $stmt = $pdo->prepare('DELETE FROM projects WHERE order_id = ?');
$stmt->execute([$id]); $stmt->execute([$id]);
@@ -515,10 +540,16 @@ function handleDeleteOrder(PDO $pdo, int $id): void
$pdo->commit(); $pdo->commit();
// Smazat NAS slozku pokud pozadovano
if ($deleteFiles && $project) {
$fm = new NasFileManager();
$fm->deleteProjectFolder($project['project_number']);
}
AuditLog::logDelete('orders_order', $id, [ AuditLog::logDelete('orders_order', $id, [
'order_number' => $order['order_number'], 'order_number' => $order['order_number'],
'quotation_id' => $order['quotation_id'], 'quotation_id' => $order['quotation_id'],
], "Smazána objednávka '{$order['order_number']}'"); ], "Smazána objednávka '{$order['order_number']}'" . ($deleteFiles ? ' (včetně souborů)' : ''));
successResponse(null, 'Objednávka byla smazána'); successResponse(null, 'Objednávka byla smazána');
} catch (PDOException $e) { } catch (PDOException $e) {

View File

@@ -0,0 +1,236 @@
<?php
/**
* Project Files API handlery
*
* Vsechny operace se soubory projektu na NAS.
*/
declare(strict_types=1);
/**
* @param array<string, mixed> $authData
*/
function handleFilesList(PDO $pdo, array $authData): void
{
$projectId = (int) ($_GET['project_id'] ?? 0);
if (!$projectId) {
errorResponse('ID projektu je povinné');
}
$project = getProjectForFiles($pdo, $projectId);
$path = $_GET['path'] ?? '';
$fm = new NasFileManager();
if (!$fm->isConfigured()) {
errorResponse('Souborový systém není nakonfigurován', 500);
}
$result = $fm->listFiles($project['project_number'], $path);
if ($result === null) {
errorResponse('Složka nebyla nalezena', 404);
}
$result['project_number'] = $project['project_number'];
$result['folder_exists'] = true;
successResponse($result);
}
/**
* @param array<string, mixed> $authData
*/
function handleFilesDownload(PDO $pdo, array $authData): void
{
$projectId = (int) ($_GET['project_id'] ?? 0);
if (!$projectId) {
errorResponse('ID projektu je povinné');
}
$project = getProjectForFiles($pdo, $projectId);
$path = $_GET['path'] ?? '';
if ($path === '') {
errorResponse('Cesta k souboru je povinná');
}
$fm = new NasFileManager();
$error = $fm->downloadFile($project['project_number'], $path);
if ($error !== null) {
errorResponse($error, 404);
}
}
/**
* @param array<string, mixed> $authData
*/
function handleFilesUpload(PDO $pdo, array $authData): void
{
$projectId = (int) ($_GET['project_id'] ?? 0);
if (!$projectId) {
errorResponse('ID projektu je povinné');
}
$project = getProjectForFiles($pdo, $projectId);
$path = $_GET['path'] ?? '';
$fm = new NasFileManager();
if (!$fm->isConfigured()) {
errorResponse('Souborový systém není nakonfigurován', 500);
}
// Vytvorit slozku pokud neexistuje
if (!$fm->projectFolderExists($project['project_number'])) {
$fm->createProjectFolder($project['project_number'], $project['name']);
}
if (empty($_FILES['file'])) {
errorResponse('Nebyl nahrán žádný soubor');
}
$error = $fm->uploadFile($project['project_number'], $path, $_FILES['file']);
if ($error !== null) {
errorResponse($error);
}
AuditLog::logCreate(
'project_file',
$projectId,
['file' => $_FILES['file']['name'] ?? '', 'path' => $path],
"Nahrán soubor do projektu '{$project['project_number']}'"
);
successResponse(null, 'Soubor byl nahrán');
}
/**
* @param array<string, mixed> $authData
*/
function handleFilesCreateFolder(PDO $pdo, array $authData): void
{
$projectId = (int) ($_GET['project_id'] ?? 0);
if (!$projectId) {
errorResponse('ID projektu je povinné');
}
$project = getProjectForFiles($pdo, $projectId);
$input = getJsonInput();
$path = $input['path'] ?? '';
$folderName = trim($input['folder_name'] ?? '');
if ($folderName === '') {
errorResponse('Název složky je povinný');
}
if (mb_strlen($folderName) > 100) {
errorResponse('Název složky je příliš dlouhý (max 100 znaků)');
}
$fm = new NasFileManager();
if (!$fm->isConfigured()) {
errorResponse('Souborový systém není nakonfigurován', 500);
}
// Vytvorit projektovou slozku pokud neexistuje
if (!$fm->projectFolderExists($project['project_number'])) {
$fm->createProjectFolder($project['project_number'], $project['name']);
}
$error = $fm->createFolder($project['project_number'], $path, $folderName);
if ($error !== null) {
errorResponse($error);
}
AuditLog::logCreate(
'project_file',
$projectId,
['folder' => $folderName, 'path' => $path],
"Vytvořena složka '$folderName' v projektu '{$project['project_number']}'"
);
successResponse(null, 'Složka byla vytvořena');
}
/**
* @param array<string, mixed> $authData
*/
function handleFilesMove(PDO $pdo, array $authData): void
{
$projectId = (int) ($_GET['project_id'] ?? 0);
if (!$projectId) {
errorResponse('ID projektu je povinné');
}
$project = getProjectForFiles($pdo, $projectId);
$input = getJsonInput();
$fromPath = $input['from_path'] ?? '';
$toPath = $input['to_path'] ?? '';
if ($fromPath === '' || $toPath === '') {
errorResponse('Zdrojová i cílová cesta jsou povinné');
}
$fm = new NasFileManager();
$error = $fm->moveItem($project['project_number'], $fromPath, $toPath);
if ($error !== null) {
errorResponse($error);
}
AuditLog::logUpdate(
'project_file',
$projectId,
['path' => $fromPath],
['path' => $toPath],
"Přesun/přejmenování v projektu '{$project['project_number']}'"
);
successResponse(null, 'Soubor byl přesunut');
}
/**
* @param array<string, mixed> $authData
*/
function handleFilesDelete(PDO $pdo, array $authData): void
{
$projectId = (int) ($_GET['project_id'] ?? 0);
if (!$projectId) {
errorResponse('ID projektu je povinné');
}
$project = getProjectForFiles($pdo, $projectId);
$path = $_GET['path'] ?? '';
if ($path === '') {
errorResponse('Cesta k souboru je povinná');
}
$fm = new NasFileManager();
$error = $fm->deleteItem($project['project_number'], $path);
if ($error !== null) {
errorResponse($error);
}
AuditLog::logDelete(
'project_file',
$projectId,
['path' => $path],
"Smazán soubor/složka v projektu '{$project['project_number']}'"
);
successResponse(null, 'Soubor byl smazán');
}
/**
* Nacte projekt z DB pro file operace
*
* @return array<string, mixed>
*/
function getProjectForFiles(PDO $pdo, int $projectId): array
{
$stmt = $pdo->prepare('SELECT id, project_number, name FROM projects WHERE id = ?');
$stmt->execute([$projectId]);
$project = $stmt->fetch();
if (!$project) {
errorResponse('Projekt nebyl nalezen', 404);
}
return $project;
}

View File

@@ -121,6 +121,10 @@ function handleCreateProject(PDO $pdo): void
'customer_id' => $customerId, 'customer_id' => $customerId,
], "Ručně vytvořen projekt '$projectNumber'"); ], "Ručně vytvořen projekt '$projectNumber'");
// Vytvorit slozku na NAS
$fm = new NasFileManager();
$fm->createProjectFolder($projectNumber, $name);
successResponse([ successResponse([
'project_id' => $projectId, 'project_id' => $projectId,
'project_number' => $projectNumber, 'project_number' => $projectNumber,
@@ -134,6 +138,9 @@ function handleCreateProject(PDO $pdo): void
function handleDeleteProject(PDO $pdo, int $id): void function handleDeleteProject(PDO $pdo, int $id): void
{ {
$input = getJsonInput();
$deleteFiles = (bool) ($input['delete_files'] ?? false);
$stmt = $pdo->prepare( $stmt = $pdo->prepare(
'SELECT id, project_number, name, order_id, status FROM projects WHERE id = ?' 'SELECT id, project_number, name, order_id, status FROM projects WHERE id = ?'
); );
@@ -161,12 +168,18 @@ function handleDeleteProject(PDO $pdo, int $id): void
$pdo->commit(); $pdo->commit();
// Smazat slozku na NAS pokud pozadovano
if ($deleteFiles) {
$fm = new NasFileManager();
$fm->deleteProjectFolder($project['project_number']);
}
AuditLog::logUpdate( AuditLog::logUpdate(
'projects_project', 'projects_project',
$id, $id,
['status' => $project['status']], ['status' => $project['status']],
['status' => 'deleted'], ['status' => 'deleted'],
"Smazán ruční projekt '{$project['project_number']}'" "Smazán ruční projekt '{$project['project_number']}'" . ($deleteFiles ? ' (včetně souborů)' : '')
); );
successResponse(null, 'Projekt byl smazán'); successResponse(null, 'Projekt byl smazán');
@@ -254,6 +267,10 @@ function handleGetDetail(PDO $pdo, int $id): void
errorResponse('Projekt nebyl nalezen', 404); errorResponse('Projekt nebyl nalezen', 404);
} }
// Kontrola existence slozky na NAS
$fm = new NasFileManager();
$project['has_nas_folder'] = $fm->projectFolderExists($project['project_number']);
successResponse($project); successResponse($project);
} }
@@ -344,6 +361,12 @@ function handleUpdateProject(PDO $pdo, int $id): void
$pdo->commit(); $pdo->commit();
// Prejmenovani slozky na NAS pokud se zmenil nazev
if ($name !== $project['name']) {
$fm = new NasFileManager();
$fm->renameProjectFolder($project['project_number'], $name);
}
AuditLog::logUpdate( AuditLog::logUpdate(
'projects_project', 'projects_project',
$id, $id,

View File

@@ -16,6 +16,7 @@ require_once dirname(__DIR__) . '/config.php';
require_once dirname(__DIR__) . '/includes/JWTAuth.php'; require_once dirname(__DIR__) . '/includes/JWTAuth.php';
require_once dirname(__DIR__) . '/includes/AuditLog.php'; require_once dirname(__DIR__) . '/includes/AuditLog.php';
require_once dirname(__DIR__) . '/includes/PaginationHelper.php'; require_once dirname(__DIR__) . '/includes/PaginationHelper.php';
require_once dirname(__DIR__) . '/includes/NasFileManager.php';
require_once __DIR__ . '/handlers/orders-handlers.php'; require_once __DIR__ . '/handlers/orders-handlers.php';
setCorsHeaders(); setCorsHeaders();

View File

@@ -0,0 +1,81 @@
<?php
/**
* Project Files API
*
* GET ?project_id=X&path=... - seznam souboru
* GET ?action=download&project_id=X&path= - stazeni souboru
* POST ?action=upload&project_id=X&path= - upload (FormData)
* POST ?action=create_folder&project_id=X - nova podslozka
* PUT ?action=move&project_id=X - presun/prejmenovani
* DELETE ?project_id=X&path=... - smazani souboru/slozky
*/
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/NasFileManager.php';
require_once __DIR__ . '/handlers/project-files-handlers.php';
setCorsHeaders();
setSecurityHeaders();
setNoCacheHeaders();
header('Content-Type: application/json; charset=utf-8');
$authData = JWTAuth::requireAuth();
AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown');
$method = $_SERVER['REQUEST_METHOD'];
$action = $_GET['action'] ?? '';
try {
$pdo = db();
switch ($method) {
case 'GET':
requirePermission($authData, 'projects.view');
if ($action === 'download') {
handleFilesDownload($pdo, $authData);
} else {
handleFilesList($pdo, $authData);
}
break;
case 'POST':
requirePermission($authData, 'projects.files');
if ($action === 'upload') {
handleFilesUpload($pdo, $authData);
} elseif ($action === 'create_folder') {
handleFilesCreateFolder($pdo, $authData);
} else {
errorResponse('Neznámá akce', 400);
}
break;
case 'PUT':
requirePermission($authData, 'projects.files');
if ($action === 'move') {
handleFilesMove($pdo, $authData);
} else {
errorResponse('Neznámá akce', 400);
}
break;
case 'DELETE':
requirePermission($authData, 'projects.files');
handleFilesDelete($pdo, $authData);
break;
default:
errorResponse('Metoda není povolena', 405);
}
} catch (PDOException $e) {
error_log('Project Files API error: ' . $e->getMessage());
if (DEBUG_MODE) {
errorResponse('Chyba databáze: ' . $e->getMessage(), 500);
} else {
errorResponse('Chyba databáze', 500);
}
}

View File

@@ -19,6 +19,7 @@ require_once dirname(__DIR__) . '/config.php';
require_once dirname(__DIR__) . '/includes/JWTAuth.php'; require_once dirname(__DIR__) . '/includes/JWTAuth.php';
require_once dirname(__DIR__) . '/includes/AuditLog.php'; require_once dirname(__DIR__) . '/includes/AuditLog.php';
require_once dirname(__DIR__) . '/includes/PaginationHelper.php'; require_once dirname(__DIR__) . '/includes/PaginationHelper.php';
require_once dirname(__DIR__) . '/includes/NasFileManager.php';
require_once __DIR__ . '/handlers/projects-handlers.php'; require_once __DIR__ . '/handlers/projects-handlers.php';
setCorsHeaders(); setCorsHeaders();

View File

@@ -0,0 +1,521 @@
<?php
/**
* NAS File Manager - filesystem operace pro projektove slozky
*
* Pracuje s namapovanym diskem (NAS_FILES_PATH).
* Vsechny cesty jsou validovany proti path traversal.
*/
declare(strict_types=1);
class NasFileManager
{
private string $basePath;
public function __construct()
{
$this->basePath = rtrim(str_replace('\\', '/', NAS_FILES_PATH), '/');
}
public function isConfigured(): bool
{
return $this->basePath !== '' && is_dir($this->basePath);
}
/**
* Vytvori projektovou slozku na NAS
*/
public function createProjectFolder(string $projectNumber, string $projectName): bool
{
if (!$this->isConfigured()) {
return false;
}
$folderName = $this->buildFolderName($projectNumber, $projectName);
$fullPath = $this->basePath . '/' . $folderName;
if (is_dir($fullPath)) {
return true;
}
return mkdir($fullPath, 0775, true);
}
/**
* Smaze projektovou slozku rekurzivne
*/
public function deleteProjectFolder(string $projectNumber): bool
{
if (!$this->isConfigured()) {
return false;
}
$folderPath = $this->findProjectFolder($projectNumber);
if ($folderPath === null) {
return true;
}
return $this->deleteRecursive($folderPath);
}
/**
* Kontrola existence projektove slozky
*/
public function projectFolderExists(string $projectNumber): bool
{
return $this->findProjectFolder($projectNumber) !== null;
}
/**
* Prejmenovani slozky pri zmene nazvu projektu
*/
public function renameProjectFolder(string $projectNumber, string $newName): bool
{
if (!$this->isConfigured()) {
return false;
}
$currentPath = $this->findProjectFolder($projectNumber);
if ($currentPath === null) {
return false;
}
$newFolderName = $this->buildFolderName($projectNumber, $newName);
$newPath = $this->basePath . '/' . $newFolderName;
if ($currentPath === $newPath) {
return true;
}
return rename($currentPath, $newPath);
}
/**
* Seznam souboru a slozek v dane ceste
*
* @return array{path: string, items: list<array<string, mixed>>}|null
*/
public function listFiles(string $projectNumber, string $subPath = ''): ?array
{
$dirPath = $this->resolveProjectPath($projectNumber, $subPath);
if ($dirPath === null || !is_dir($dirPath)) {
return null;
}
$entries = scandir($dirPath);
if ($entries === false) {
return null;
}
$items = [];
foreach ($entries as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
$fullPath = $dirPath . '/' . $entry;
$isDir = is_dir($fullPath);
$item = [
'name' => $entry,
'type' => $isDir ? 'folder' : 'file',
'modified' => date('Y-m-d H:i', filemtime($fullPath) ?: 0),
];
if ($isDir) {
$item['item_count'] = $this->countItems($fullPath);
} else {
$size = filesize($fullPath);
$item['size'] = $size;
$item['size_formatted'] = $this->formatFileSize($size ?: 0);
$item['extension'] = strtolower(pathinfo($entry, PATHINFO_EXTENSION));
}
$items[] = $item;
}
// Slozky prvni, pak soubory - obe abecedne
usort($items, function (array $a, array $b): int {
if ($a['type'] !== $b['type']) {
return $a['type'] === 'folder' ? -1 : 1;
}
return strnatcasecmp($a['name'], $b['name']);
});
$breadcrumb = [''];
if ($subPath !== '') {
$parts = explode('/', trim($subPath, '/'));
foreach ($parts as $part) {
$breadcrumb[] = $part;
}
}
return [
'path' => $subPath,
'items' => $items,
'breadcrumb' => $breadcrumb,
];
}
/**
* Upload souboru
*
* @param array<string, mixed> $uploadedFile $_FILES element
*/
public function uploadFile(string $projectNumber, string $subPath, array $uploadedFile): ?string
{
$dirPath = $this->resolveProjectPath($projectNumber, $subPath);
if ($dirPath === null || !is_dir($dirPath)) {
return 'Cílová složka neexistuje';
}
if (($uploadedFile['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
return 'Chyba při nahrávání souboru';
}
$size = (int) ($uploadedFile['size'] ?? 0);
if ($size > NAS_MAX_UPLOAD_SIZE) {
$maxMb = round(NAS_MAX_UPLOAD_SIZE / 1048576);
return "Soubor je příliš velký (max {$maxMb} MB)";
}
$originalName = basename($uploadedFile['name'] ?? '');
$safeName = $this->sanitizeFilename($originalName);
if ($safeName === '') {
return 'Neplatný název souboru';
}
$ext = strtolower(pathinfo($safeName, PATHINFO_EXTENSION));
if (in_array($ext, NAS_BLOCKED_EXTENSIONS, true)) {
return 'Tento typ souboru není povolen';
}
if (!empty(NAS_ALLOWED_EXTENSIONS) && !in_array($ext, NAS_ALLOWED_EXTENSIONS, true)) {
return 'Tento typ souboru není povolen';
}
// MIME validace
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($uploadedFile['tmp_name']);
if ($this->isSuspiciousMime($mime ?: '', $ext)) {
return 'Obsah souboru neodpovídá jeho příponě';
}
$destPath = $dirPath . '/' . $safeName;
// Pokud soubor existuje, pridej cislo
if (file_exists($destPath)) {
$base = pathinfo($safeName, PATHINFO_FILENAME);
$counter = 1;
do {
$safeName = $base . '_' . $counter . '.' . $ext;
$destPath = $dirPath . '/' . $safeName;
$counter++;
} while (file_exists($destPath));
}
if (!move_uploaded_file($uploadedFile['tmp_name'], $destPath)) {
return 'Nepodařilo se uložit soubor';
}
return null;
}
/**
* Smaze soubor nebo slozku
*/
public function deleteItem(string $projectNumber, string $filePath): ?string
{
if ($filePath === '' || $filePath === '/') {
return 'Nelze smazat kořenovou složku projektu';
}
$fullPath = $this->resolveProjectPath($projectNumber, $filePath);
if ($fullPath === null) {
return 'Neplatná cesta';
}
if (!file_exists($fullPath)) {
return 'Soubor nebo složka neexistuje';
}
if (is_dir($fullPath)) {
if (!$this->deleteRecursive($fullPath)) {
return 'Nepodařilo se smazat složku';
}
} else {
if (!unlink($fullPath)) {
return 'Nepodařilo se smazat soubor';
}
}
return null;
}
/**
* Presun/prejmenovani souboru nebo slozky
*/
public function moveItem(string $projectNumber, string $fromPath, string $toPath): ?string
{
if ($fromPath === '' || $fromPath === '/') {
return 'Nelze přesunout kořenovou složku';
}
$fullFrom = $this->resolveProjectPath($projectNumber, $fromPath);
$fullTo = $this->resolveProjectPath($projectNumber, $toPath);
if ($fullFrom === null || $fullTo === null) {
return 'Neplatná cesta';
}
if (!file_exists($fullFrom)) {
return 'Zdrojový soubor neexistuje';
}
// Case-insensitive FS (Windows) - povolit zmenu velikosti pismen
$sameFile = str_ireplace('\\', '/', $fullFrom) === str_ireplace('\\', '/', $fullTo);
if (file_exists($fullTo) && !$sameFile) {
return 'Cílový soubor již existuje';
}
// Validace nazvu u ciloveho souboru
$targetName = basename($toPath);
if ($this->sanitizeFilename($targetName) !== $targetName) {
return 'Neplatný cílový název';
}
if (!rename($fullFrom, $fullTo)) {
return 'Nepodařilo se přesunout soubor';
}
return null;
}
/**
* Vytvori podslozku
*/
public function createFolder(string $projectNumber, string $subPath, string $folderName): ?string
{
$dirPath = $this->resolveProjectPath($projectNumber, $subPath);
if ($dirPath === null || !is_dir($dirPath)) {
return 'Nadřazená složka neexistuje';
}
$safeName = $this->sanitizeFilename($folderName);
if ($safeName === '') {
return 'Neplatný název složky';
}
$newPath = $dirPath . '/' . $safeName;
if (file_exists($newPath)) {
return 'Složka s tímto názvem již existuje';
}
if (!mkdir($newPath, 0775)) {
return 'Nepodařilo se vytvořit složku';
}
return null;
}
/**
* Streamuje soubor ke stazeni
*/
public function downloadFile(string $projectNumber, string $filePath): ?string
{
$fullPath = $this->resolveProjectPath($projectNumber, $filePath);
if ($fullPath === null || !is_file($fullPath)) {
return 'Soubor nebyl nalezen';
}
$filename = basename($fullPath);
$size = filesize($fullPath);
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($fullPath) ?: 'application/octet-stream';
header_remove('Content-Type');
header('Content-Type: ' . $mime);
header('Content-Disposition: attachment; filename="' . $this->sanitizeFilename($filename) . '"');
header('Content-Length: ' . $size);
header('Cache-Control: no-cache');
readfile($fullPath);
exit;
}
// --- Private helpers ---
/**
* Najde existujici projektovou slozku podle prefixu cisla projektu
*/
private function findProjectFolder(string $projectNumber): ?string
{
if (!$this->isConfigured()) {
return null;
}
$entries = scandir($this->basePath);
if ($entries === false) {
return null;
}
$prefix = $projectNumber . '_';
foreach ($entries as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
if (str_starts_with($entry, $prefix) && is_dir($this->basePath . '/' . $entry)) {
return $this->basePath . '/' . $entry;
}
}
return null;
}
/**
* Sestavi nazev slozky: {number}_{name_s_podtrzitky}
*/
private function buildFolderName(string $projectNumber, string $projectName): string
{
$safe = preg_replace('/[^\p{L}\p{N}_\-. ]/u', '', $projectName) ?? '';
$safe = str_replace(' ', '_', trim($safe));
$safe = preg_replace('/_+/', '_', $safe) ?? $safe;
$safe = mb_substr($safe, 0, 200);
return $projectNumber . '_' . $safe;
}
/**
* Resolve a project sub-path to a safe absolute path
* Returns null if path traversal detected
*/
private function resolveProjectPath(string $projectNumber, string $subPath): ?string
{
$folderPath = $this->findProjectFolder($projectNumber);
if ($folderPath === null) {
return null;
}
if ($subPath === '' || $subPath === '/') {
return $folderPath;
}
// Zakladni path traversal ochrana
if (str_contains($subPath, "\0") || str_contains($subPath, '..')) {
return null;
}
$subPath = str_replace('\\', '/', $subPath);
$subPath = trim($subPath, '/');
$candidate = $folderPath . '/' . $subPath;
$normalBase = str_replace('\\', '/', $folderPath);
// realpath kontrola - soubor/slozka musi existovat pro existujici cesty
if (file_exists($candidate)) {
$real = realpath($candidate);
$normalReal = str_replace('\\', '/', (string) $real);
if ($real === false || !str_starts_with($normalReal, $normalBase)) {
return null;
}
return $normalReal;
}
// Pro nove soubory/slozky - kontrola rodice
$parentDir = dirname($candidate);
if (file_exists($parentDir)) {
$realParent = realpath($parentDir);
$normalParent = str_replace('\\', '/', (string) $realParent);
if ($realParent === false || !str_starts_with($normalParent, $normalBase)) {
return null;
}
}
return $candidate;
}
private function deleteRecursive(string $path): bool
{
if (is_file($path)) {
return unlink($path);
}
$entries = scandir($path);
if ($entries === false) {
return false;
}
foreach ($entries as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
$fullPath = $path . '/' . $entry;
if (!$this->deleteRecursive($fullPath)) {
return false;
}
}
return rmdir($path);
}
private function countItems(string $dirPath): int
{
$entries = scandir($dirPath);
if ($entries === false) {
return 0;
}
return max(0, count($entries) - 2);
}
public function sanitizeFilename(string $name): string
{
$name = basename($name);
$name = preg_replace('/[\x00-\x1f\x7f<>:"\/\\\\|?*]/', '', $name) ?? '';
$name = trim($name, '. ');
if (mb_strlen($name) > 255) {
$ext = pathinfo($name, PATHINFO_EXTENSION);
$base = mb_substr(pathinfo($name, PATHINFO_FILENAME), 0, 250 - mb_strlen($ext));
$name = $ext ? $base . '.' . $ext : $base;
}
return $name;
}
public function formatFileSize(int $bytes): string
{
if ($bytes < 1024) {
return $bytes . ' B';
}
if ($bytes < 1048576) {
return round($bytes / 1024, 1) . ' KB';
}
if ($bytes < 1073741824) {
return round($bytes / 1048576, 1) . ' MB';
}
return round($bytes / 1073741824, 1) . ' GB';
}
/**
* Detekce podezrelych MIME typů (napr. exe maskujici se jako jpg)
*/
private function isSuspiciousMime(string $mime, string $ext): bool
{
$executableMimes = [
'application/x-executable',
'application/x-msdos-program',
'application/x-dosexec',
'application/x-msdownload',
];
if (in_array($mime, $executableMimes, true)) {
return true;
}
// PHP soubory
if (str_contains($mime, 'php') || str_contains($mime, 'x-httpd')) {
return true;
}
return false;
}
}

View File

@@ -36,3 +36,12 @@ define('INCLUDES_PATH', API_ROOT . '/includes');
// Rate limiting // Rate limiting
define('RATE_LIMIT_STORAGE_PATH', dirname(__DIR__) . '/rate_limits'); define('RATE_LIMIT_STORAGE_PATH', dirname(__DIR__) . '/rate_limits');
// NAS File Manager
define('NAS_FILES_PATH', env('NAS_FILES_PATH', ''));
define('NAS_MAX_UPLOAD_SIZE', (int) env('NAS_MAX_UPLOAD_SIZE', 52428800));
define(
'NAS_ALLOWED_EXTENSIONS',
array_filter(array_map('trim', explode(',', (string) env('NAS_ALLOWED_EXTENSIONS', ''))))
);
define('NAS_BLOCKED_EXTENSIONS', ['exe', 'bat', 'sh', 'php', 'htaccess', 'env', 'cmd', 'com', 'msi', 'ps1']);

View File

@@ -128,6 +128,11 @@ function handleGetDetail(PDO $pdo, int $id): void
$stmt->execute([$id]); $stmt->execute([$id]);
$order['project'] = $stmt->fetch() ?: null; $order['project'] = $stmt->fetch() ?: null;
if ($order['project']) {
$fm = new NasFileManager();
$order['project']['has_nas_folder'] = $fm->projectFolderExists($order['project']['project_number']);
}
// Get linked invoice // Get linked invoice
$stmt = $pdo->prepare('SELECT id, invoice_number, status FROM invoices WHERE order_id = ? LIMIT 1'); $stmt = $pdo->prepare('SELECT id, invoice_number, status FROM invoices WHERE order_id = ? LIMIT 1');
$stmt->execute([$id]); $stmt->execute([$id]);
@@ -352,6 +357,10 @@ function handleCreateOrder(PDO $pdo): void
$pdo->commit(); $pdo->commit();
$pdo->query("SELECT RELEASE_LOCK('boha_order_number')"); $pdo->query("SELECT RELEASE_LOCK('boha_order_number')");
// Vytvorit slozku na NAS pro novy projekt
$fm = new NasFileManager();
$fm->createProjectFolder($orderNumber, $projectName);
AuditLog::logCreate('orders_order', $orderId, [ AuditLog::logCreate('orders_order', $orderId, [
'order_number' => $orderNumber, 'order_number' => $orderNumber,
'quotation_number' => $quotation['quotation_number'], 'quotation_number' => $quotation['quotation_number'],
@@ -482,6 +491,9 @@ function handleUpdateOrder(PDO $pdo, int $id): void
function handleDeleteOrder(PDO $pdo, int $id): void function handleDeleteOrder(PDO $pdo, int $id): void
{ {
$input = getJsonInput();
$deleteFiles = (bool) ($input['delete_files'] ?? false);
$stmt = $pdo->prepare( $stmt = $pdo->prepare(
'SELECT id, order_number, quotation_id FROM orders WHERE id = ?' 'SELECT id, order_number, quotation_id FROM orders WHERE id = ?'
); );
@@ -492,8 +504,21 @@ function handleDeleteOrder(PDO $pdo, int $id): void
errorResponse('Objednávka nebyla nalezena', 404); errorResponse('Objednávka nebyla nalezena', 404);
} }
// Nacist projekt pred smazanim (pro NAS slozku)
$stmt = $pdo->prepare('SELECT project_number FROM projects WHERE order_id = ?');
$stmt->execute([$id]);
$project = $stmt->fetch();
$pdo->beginTransaction(); $pdo->beginTransaction();
try { try {
// Delete project notes
if ($project) {
$stmt = $pdo->prepare(
'DELETE FROM project_notes WHERE project_id IN (SELECT id FROM projects WHERE order_id = ?)'
);
$stmt->execute([$id]);
}
// Delete project linked to this order // Delete project linked to this order
$stmt = $pdo->prepare('DELETE FROM projects WHERE order_id = ?'); $stmt = $pdo->prepare('DELETE FROM projects WHERE order_id = ?');
$stmt->execute([$id]); $stmt->execute([$id]);
@@ -515,10 +540,16 @@ function handleDeleteOrder(PDO $pdo, int $id): void
$pdo->commit(); $pdo->commit();
// Smazat NAS slozku pokud pozadovano
if ($deleteFiles && $project) {
$fm = new NasFileManager();
$fm->deleteProjectFolder($project['project_number']);
}
AuditLog::logDelete('orders_order', $id, [ AuditLog::logDelete('orders_order', $id, [
'order_number' => $order['order_number'], 'order_number' => $order['order_number'],
'quotation_id' => $order['quotation_id'], 'quotation_id' => $order['quotation_id'],
], "Smazána objednávka '{$order['order_number']}'"); ], "Smazána objednávka '{$order['order_number']}'" . ($deleteFiles ? ' (včetně souborů)' : ''));
successResponse(null, 'Objednávka byla smazána'); successResponse(null, 'Objednávka byla smazána');
} catch (PDOException $e) { } catch (PDOException $e) {

View File

@@ -0,0 +1,236 @@
<?php
/**
* Project Files API handlery
*
* Vsechny operace se soubory projektu na NAS.
*/
declare(strict_types=1);
/**
* @param array<string, mixed> $authData
*/
function handleFilesList(PDO $pdo, array $authData): void
{
$projectId = (int) ($_GET['project_id'] ?? 0);
if (!$projectId) {
errorResponse('ID projektu je povinné');
}
$project = getProjectForFiles($pdo, $projectId);
$path = $_GET['path'] ?? '';
$fm = new NasFileManager();
if (!$fm->isConfigured()) {
errorResponse('Souborový systém není nakonfigurován', 500);
}
$result = $fm->listFiles($project['project_number'], $path);
if ($result === null) {
errorResponse('Složka nebyla nalezena', 404);
}
$result['project_number'] = $project['project_number'];
$result['folder_exists'] = true;
successResponse($result);
}
/**
* @param array<string, mixed> $authData
*/
function handleFilesDownload(PDO $pdo, array $authData): void
{
$projectId = (int) ($_GET['project_id'] ?? 0);
if (!$projectId) {
errorResponse('ID projektu je povinné');
}
$project = getProjectForFiles($pdo, $projectId);
$path = $_GET['path'] ?? '';
if ($path === '') {
errorResponse('Cesta k souboru je povinná');
}
$fm = new NasFileManager();
$error = $fm->downloadFile($project['project_number'], $path);
if ($error !== null) {
errorResponse($error, 404);
}
}
/**
* @param array<string, mixed> $authData
*/
function handleFilesUpload(PDO $pdo, array $authData): void
{
$projectId = (int) ($_GET['project_id'] ?? 0);
if (!$projectId) {
errorResponse('ID projektu je povinné');
}
$project = getProjectForFiles($pdo, $projectId);
$path = $_GET['path'] ?? '';
$fm = new NasFileManager();
if (!$fm->isConfigured()) {
errorResponse('Souborový systém není nakonfigurován', 500);
}
// Vytvorit slozku pokud neexistuje
if (!$fm->projectFolderExists($project['project_number'])) {
$fm->createProjectFolder($project['project_number'], $project['name']);
}
if (empty($_FILES['file'])) {
errorResponse('Nebyl nahrán žádný soubor');
}
$error = $fm->uploadFile($project['project_number'], $path, $_FILES['file']);
if ($error !== null) {
errorResponse($error);
}
AuditLog::logCreate(
'project_file',
$projectId,
['file' => $_FILES['file']['name'] ?? '', 'path' => $path],
"Nahrán soubor do projektu '{$project['project_number']}'"
);
successResponse(null, 'Soubor byl nahrán');
}
/**
* @param array<string, mixed> $authData
*/
function handleFilesCreateFolder(PDO $pdo, array $authData): void
{
$projectId = (int) ($_GET['project_id'] ?? 0);
if (!$projectId) {
errorResponse('ID projektu je povinné');
}
$project = getProjectForFiles($pdo, $projectId);
$input = getJsonInput();
$path = $input['path'] ?? '';
$folderName = trim($input['folder_name'] ?? '');
if ($folderName === '') {
errorResponse('Název složky je povinný');
}
if (mb_strlen($folderName) > 100) {
errorResponse('Název složky je příliš dlouhý (max 100 znaků)');
}
$fm = new NasFileManager();
if (!$fm->isConfigured()) {
errorResponse('Souborový systém není nakonfigurován', 500);
}
// Vytvorit projektovou slozku pokud neexistuje
if (!$fm->projectFolderExists($project['project_number'])) {
$fm->createProjectFolder($project['project_number'], $project['name']);
}
$error = $fm->createFolder($project['project_number'], $path, $folderName);
if ($error !== null) {
errorResponse($error);
}
AuditLog::logCreate(
'project_file',
$projectId,
['folder' => $folderName, 'path' => $path],
"Vytvořena složka '$folderName' v projektu '{$project['project_number']}'"
);
successResponse(null, 'Složka byla vytvořena');
}
/**
* @param array<string, mixed> $authData
*/
function handleFilesMove(PDO $pdo, array $authData): void
{
$projectId = (int) ($_GET['project_id'] ?? 0);
if (!$projectId) {
errorResponse('ID projektu je povinné');
}
$project = getProjectForFiles($pdo, $projectId);
$input = getJsonInput();
$fromPath = $input['from_path'] ?? '';
$toPath = $input['to_path'] ?? '';
if ($fromPath === '' || $toPath === '') {
errorResponse('Zdrojová i cílová cesta jsou povinné');
}
$fm = new NasFileManager();
$error = $fm->moveItem($project['project_number'], $fromPath, $toPath);
if ($error !== null) {
errorResponse($error);
}
AuditLog::logUpdate(
'project_file',
$projectId,
['path' => $fromPath],
['path' => $toPath],
"Přesun/přejmenování v projektu '{$project['project_number']}'"
);
successResponse(null, 'Soubor byl přesunut');
}
/**
* @param array<string, mixed> $authData
*/
function handleFilesDelete(PDO $pdo, array $authData): void
{
$projectId = (int) ($_GET['project_id'] ?? 0);
if (!$projectId) {
errorResponse('ID projektu je povinné');
}
$project = getProjectForFiles($pdo, $projectId);
$path = $_GET['path'] ?? '';
if ($path === '') {
errorResponse('Cesta k souboru je povinná');
}
$fm = new NasFileManager();
$error = $fm->deleteItem($project['project_number'], $path);
if ($error !== null) {
errorResponse($error);
}
AuditLog::logDelete(
'project_file',
$projectId,
['path' => $path],
"Smazán soubor/složka v projektu '{$project['project_number']}'"
);
successResponse(null, 'Soubor byl smazán');
}
/**
* Nacte projekt z DB pro file operace
*
* @return array<string, mixed>
*/
function getProjectForFiles(PDO $pdo, int $projectId): array
{
$stmt = $pdo->prepare('SELECT id, project_number, name FROM projects WHERE id = ?');
$stmt->execute([$projectId]);
$project = $stmt->fetch();
if (!$project) {
errorResponse('Projekt nebyl nalezen', 404);
}
return $project;
}

View File

@@ -121,6 +121,10 @@ function handleCreateProject(PDO $pdo): void
'customer_id' => $customerId, 'customer_id' => $customerId,
], "Ručně vytvořen projekt '$projectNumber'"); ], "Ručně vytvořen projekt '$projectNumber'");
// Vytvorit slozku na NAS
$fm = new NasFileManager();
$fm->createProjectFolder($projectNumber, $name);
successResponse([ successResponse([
'project_id' => $projectId, 'project_id' => $projectId,
'project_number' => $projectNumber, 'project_number' => $projectNumber,
@@ -134,6 +138,9 @@ function handleCreateProject(PDO $pdo): void
function handleDeleteProject(PDO $pdo, int $id): void function handleDeleteProject(PDO $pdo, int $id): void
{ {
$input = getJsonInput();
$deleteFiles = (bool) ($input['delete_files'] ?? false);
$stmt = $pdo->prepare( $stmt = $pdo->prepare(
'SELECT id, project_number, name, order_id, status FROM projects WHERE id = ?' 'SELECT id, project_number, name, order_id, status FROM projects WHERE id = ?'
); );
@@ -161,12 +168,18 @@ function handleDeleteProject(PDO $pdo, int $id): void
$pdo->commit(); $pdo->commit();
// Smazat slozku na NAS pokud pozadovano
if ($deleteFiles) {
$fm = new NasFileManager();
$fm->deleteProjectFolder($project['project_number']);
}
AuditLog::logUpdate( AuditLog::logUpdate(
'projects_project', 'projects_project',
$id, $id,
['status' => $project['status']], ['status' => $project['status']],
['status' => 'deleted'], ['status' => 'deleted'],
"Smazán ruční projekt '{$project['project_number']}'" "Smazán ruční projekt '{$project['project_number']}'" . ($deleteFiles ? ' (včetně souborů)' : '')
); );
successResponse(null, 'Projekt byl smazán'); successResponse(null, 'Projekt byl smazán');
@@ -254,6 +267,10 @@ function handleGetDetail(PDO $pdo, int $id): void
errorResponse('Projekt nebyl nalezen', 404); errorResponse('Projekt nebyl nalezen', 404);
} }
// Kontrola existence slozky na NAS
$fm = new NasFileManager();
$project['has_nas_folder'] = $fm->projectFolderExists($project['project_number']);
successResponse($project); successResponse($project);
} }
@@ -344,6 +361,12 @@ function handleUpdateProject(PDO $pdo, int $id): void
$pdo->commit(); $pdo->commit();
// Prejmenovani slozky na NAS pokud se zmenil nazev
if ($name !== $project['name']) {
$fm = new NasFileManager();
$fm->renameProjectFolder($project['project_number'], $name);
}
AuditLog::logUpdate( AuditLog::logUpdate(
'projects_project', 'projects_project',
$id, $id,

View File

@@ -16,6 +16,7 @@ require_once dirname(__DIR__) . '/config.php';
require_once dirname(__DIR__) . '/includes/JWTAuth.php'; require_once dirname(__DIR__) . '/includes/JWTAuth.php';
require_once dirname(__DIR__) . '/includes/AuditLog.php'; require_once dirname(__DIR__) . '/includes/AuditLog.php';
require_once dirname(__DIR__) . '/includes/PaginationHelper.php'; require_once dirname(__DIR__) . '/includes/PaginationHelper.php';
require_once dirname(__DIR__) . '/includes/NasFileManager.php';
require_once __DIR__ . '/handlers/orders-handlers.php'; require_once __DIR__ . '/handlers/orders-handlers.php';
setCorsHeaders(); setCorsHeaders();

81
dist/api/admin/project-files.php vendored Normal file
View File

@@ -0,0 +1,81 @@
<?php
/**
* Project Files API
*
* GET ?project_id=X&path=... - seznam souboru
* GET ?action=download&project_id=X&path= - stazeni souboru
* POST ?action=upload&project_id=X&path= - upload (FormData)
* POST ?action=create_folder&project_id=X - nova podslozka
* PUT ?action=move&project_id=X - presun/prejmenovani
* DELETE ?project_id=X&path=... - smazani souboru/slozky
*/
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/NasFileManager.php';
require_once __DIR__ . '/handlers/project-files-handlers.php';
setCorsHeaders();
setSecurityHeaders();
setNoCacheHeaders();
header('Content-Type: application/json; charset=utf-8');
$authData = JWTAuth::requireAuth();
AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown');
$method = $_SERVER['REQUEST_METHOD'];
$action = $_GET['action'] ?? '';
try {
$pdo = db();
switch ($method) {
case 'GET':
requirePermission($authData, 'projects.view');
if ($action === 'download') {
handleFilesDownload($pdo, $authData);
} else {
handleFilesList($pdo, $authData);
}
break;
case 'POST':
requirePermission($authData, 'projects.files');
if ($action === 'upload') {
handleFilesUpload($pdo, $authData);
} elseif ($action === 'create_folder') {
handleFilesCreateFolder($pdo, $authData);
} else {
errorResponse('Neznámá akce', 400);
}
break;
case 'PUT':
requirePermission($authData, 'projects.files');
if ($action === 'move') {
handleFilesMove($pdo, $authData);
} else {
errorResponse('Neznámá akce', 400);
}
break;
case 'DELETE':
requirePermission($authData, 'projects.files');
handleFilesDelete($pdo, $authData);
break;
default:
errorResponse('Metoda není povolena', 405);
}
} catch (PDOException $e) {
error_log('Project Files API error: ' . $e->getMessage());
if (DEBUG_MODE) {
errorResponse('Chyba databáze: ' . $e->getMessage(), 500);
} else {
errorResponse('Chyba databáze', 500);
}
}

View File

@@ -19,6 +19,7 @@ require_once dirname(__DIR__) . '/config.php';
require_once dirname(__DIR__) . '/includes/JWTAuth.php'; require_once dirname(__DIR__) . '/includes/JWTAuth.php';
require_once dirname(__DIR__) . '/includes/AuditLog.php'; require_once dirname(__DIR__) . '/includes/AuditLog.php';
require_once dirname(__DIR__) . '/includes/PaginationHelper.php'; require_once dirname(__DIR__) . '/includes/PaginationHelper.php';
require_once dirname(__DIR__) . '/includes/NasFileManager.php';
require_once __DIR__ . '/handlers/projects-handlers.php'; require_once __DIR__ . '/handlers/projects-handlers.php';
setCorsHeaders(); setCorsHeaders();

521
dist/api/includes/NasFileManager.php vendored Normal file
View File

@@ -0,0 +1,521 @@
<?php
/**
* NAS File Manager - filesystem operace pro projektove slozky
*
* Pracuje s namapovanym diskem (NAS_FILES_PATH).
* Vsechny cesty jsou validovany proti path traversal.
*/
declare(strict_types=1);
class NasFileManager
{
private string $basePath;
public function __construct()
{
$this->basePath = rtrim(str_replace('\\', '/', NAS_FILES_PATH), '/');
}
public function isConfigured(): bool
{
return $this->basePath !== '' && is_dir($this->basePath);
}
/**
* Vytvori projektovou slozku na NAS
*/
public function createProjectFolder(string $projectNumber, string $projectName): bool
{
if (!$this->isConfigured()) {
return false;
}
$folderName = $this->buildFolderName($projectNumber, $projectName);
$fullPath = $this->basePath . '/' . $folderName;
if (is_dir($fullPath)) {
return true;
}
return mkdir($fullPath, 0775, true);
}
/**
* Smaze projektovou slozku rekurzivne
*/
public function deleteProjectFolder(string $projectNumber): bool
{
if (!$this->isConfigured()) {
return false;
}
$folderPath = $this->findProjectFolder($projectNumber);
if ($folderPath === null) {
return true;
}
return $this->deleteRecursive($folderPath);
}
/**
* Kontrola existence projektove slozky
*/
public function projectFolderExists(string $projectNumber): bool
{
return $this->findProjectFolder($projectNumber) !== null;
}
/**
* Prejmenovani slozky pri zmene nazvu projektu
*/
public function renameProjectFolder(string $projectNumber, string $newName): bool
{
if (!$this->isConfigured()) {
return false;
}
$currentPath = $this->findProjectFolder($projectNumber);
if ($currentPath === null) {
return false;
}
$newFolderName = $this->buildFolderName($projectNumber, $newName);
$newPath = $this->basePath . '/' . $newFolderName;
if ($currentPath === $newPath) {
return true;
}
return rename($currentPath, $newPath);
}
/**
* Seznam souboru a slozek v dane ceste
*
* @return array{path: string, items: list<array<string, mixed>>}|null
*/
public function listFiles(string $projectNumber, string $subPath = ''): ?array
{
$dirPath = $this->resolveProjectPath($projectNumber, $subPath);
if ($dirPath === null || !is_dir($dirPath)) {
return null;
}
$entries = scandir($dirPath);
if ($entries === false) {
return null;
}
$items = [];
foreach ($entries as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
$fullPath = $dirPath . '/' . $entry;
$isDir = is_dir($fullPath);
$item = [
'name' => $entry,
'type' => $isDir ? 'folder' : 'file',
'modified' => date('Y-m-d H:i', filemtime($fullPath) ?: 0),
];
if ($isDir) {
$item['item_count'] = $this->countItems($fullPath);
} else {
$size = filesize($fullPath);
$item['size'] = $size;
$item['size_formatted'] = $this->formatFileSize($size ?: 0);
$item['extension'] = strtolower(pathinfo($entry, PATHINFO_EXTENSION));
}
$items[] = $item;
}
// Slozky prvni, pak soubory - obe abecedne
usort($items, function (array $a, array $b): int {
if ($a['type'] !== $b['type']) {
return $a['type'] === 'folder' ? -1 : 1;
}
return strnatcasecmp($a['name'], $b['name']);
});
$breadcrumb = [''];
if ($subPath !== '') {
$parts = explode('/', trim($subPath, '/'));
foreach ($parts as $part) {
$breadcrumb[] = $part;
}
}
return [
'path' => $subPath,
'items' => $items,
'breadcrumb' => $breadcrumb,
];
}
/**
* Upload souboru
*
* @param array<string, mixed> $uploadedFile $_FILES element
*/
public function uploadFile(string $projectNumber, string $subPath, array $uploadedFile): ?string
{
$dirPath = $this->resolveProjectPath($projectNumber, $subPath);
if ($dirPath === null || !is_dir($dirPath)) {
return 'Cílová složka neexistuje';
}
if (($uploadedFile['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
return 'Chyba při nahrávání souboru';
}
$size = (int) ($uploadedFile['size'] ?? 0);
if ($size > NAS_MAX_UPLOAD_SIZE) {
$maxMb = round(NAS_MAX_UPLOAD_SIZE / 1048576);
return "Soubor je příliš velký (max {$maxMb} MB)";
}
$originalName = basename($uploadedFile['name'] ?? '');
$safeName = $this->sanitizeFilename($originalName);
if ($safeName === '') {
return 'Neplatný název souboru';
}
$ext = strtolower(pathinfo($safeName, PATHINFO_EXTENSION));
if (in_array($ext, NAS_BLOCKED_EXTENSIONS, true)) {
return 'Tento typ souboru není povolen';
}
if (!empty(NAS_ALLOWED_EXTENSIONS) && !in_array($ext, NAS_ALLOWED_EXTENSIONS, true)) {
return 'Tento typ souboru není povolen';
}
// MIME validace
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($uploadedFile['tmp_name']);
if ($this->isSuspiciousMime($mime ?: '', $ext)) {
return 'Obsah souboru neodpovídá jeho příponě';
}
$destPath = $dirPath . '/' . $safeName;
// Pokud soubor existuje, pridej cislo
if (file_exists($destPath)) {
$base = pathinfo($safeName, PATHINFO_FILENAME);
$counter = 1;
do {
$safeName = $base . '_' . $counter . '.' . $ext;
$destPath = $dirPath . '/' . $safeName;
$counter++;
} while (file_exists($destPath));
}
if (!move_uploaded_file($uploadedFile['tmp_name'], $destPath)) {
return 'Nepodařilo se uložit soubor';
}
return null;
}
/**
* Smaze soubor nebo slozku
*/
public function deleteItem(string $projectNumber, string $filePath): ?string
{
if ($filePath === '' || $filePath === '/') {
return 'Nelze smazat kořenovou složku projektu';
}
$fullPath = $this->resolveProjectPath($projectNumber, $filePath);
if ($fullPath === null) {
return 'Neplatná cesta';
}
if (!file_exists($fullPath)) {
return 'Soubor nebo složka neexistuje';
}
if (is_dir($fullPath)) {
if (!$this->deleteRecursive($fullPath)) {
return 'Nepodařilo se smazat složku';
}
} else {
if (!unlink($fullPath)) {
return 'Nepodařilo se smazat soubor';
}
}
return null;
}
/**
* Presun/prejmenovani souboru nebo slozky
*/
public function moveItem(string $projectNumber, string $fromPath, string $toPath): ?string
{
if ($fromPath === '' || $fromPath === '/') {
return 'Nelze přesunout kořenovou složku';
}
$fullFrom = $this->resolveProjectPath($projectNumber, $fromPath);
$fullTo = $this->resolveProjectPath($projectNumber, $toPath);
if ($fullFrom === null || $fullTo === null) {
return 'Neplatná cesta';
}
if (!file_exists($fullFrom)) {
return 'Zdrojový soubor neexistuje';
}
// Case-insensitive FS (Windows) - povolit zmenu velikosti pismen
$sameFile = str_ireplace('\\', '/', $fullFrom) === str_ireplace('\\', '/', $fullTo);
if (file_exists($fullTo) && !$sameFile) {
return 'Cílový soubor již existuje';
}
// Validace nazvu u ciloveho souboru
$targetName = basename($toPath);
if ($this->sanitizeFilename($targetName) !== $targetName) {
return 'Neplatný cílový název';
}
if (!rename($fullFrom, $fullTo)) {
return 'Nepodařilo se přesunout soubor';
}
return null;
}
/**
* Vytvori podslozku
*/
public function createFolder(string $projectNumber, string $subPath, string $folderName): ?string
{
$dirPath = $this->resolveProjectPath($projectNumber, $subPath);
if ($dirPath === null || !is_dir($dirPath)) {
return 'Nadřazená složka neexistuje';
}
$safeName = $this->sanitizeFilename($folderName);
if ($safeName === '') {
return 'Neplatný název složky';
}
$newPath = $dirPath . '/' . $safeName;
if (file_exists($newPath)) {
return 'Složka s tímto názvem již existuje';
}
if (!mkdir($newPath, 0775)) {
return 'Nepodařilo se vytvořit složku';
}
return null;
}
/**
* Streamuje soubor ke stazeni
*/
public function downloadFile(string $projectNumber, string $filePath): ?string
{
$fullPath = $this->resolveProjectPath($projectNumber, $filePath);
if ($fullPath === null || !is_file($fullPath)) {
return 'Soubor nebyl nalezen';
}
$filename = basename($fullPath);
$size = filesize($fullPath);
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($fullPath) ?: 'application/octet-stream';
header_remove('Content-Type');
header('Content-Type: ' . $mime);
header('Content-Disposition: attachment; filename="' . $this->sanitizeFilename($filename) . '"');
header('Content-Length: ' . $size);
header('Cache-Control: no-cache');
readfile($fullPath);
exit;
}
// --- Private helpers ---
/**
* Najde existujici projektovou slozku podle prefixu cisla projektu
*/
private function findProjectFolder(string $projectNumber): ?string
{
if (!$this->isConfigured()) {
return null;
}
$entries = scandir($this->basePath);
if ($entries === false) {
return null;
}
$prefix = $projectNumber . '_';
foreach ($entries as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
if (str_starts_with($entry, $prefix) && is_dir($this->basePath . '/' . $entry)) {
return $this->basePath . '/' . $entry;
}
}
return null;
}
/**
* Sestavi nazev slozky: {number}_{name_s_podtrzitky}
*/
private function buildFolderName(string $projectNumber, string $projectName): string
{
$safe = preg_replace('/[^\p{L}\p{N}_\-. ]/u', '', $projectName) ?? '';
$safe = str_replace(' ', '_', trim($safe));
$safe = preg_replace('/_+/', '_', $safe) ?? $safe;
$safe = mb_substr($safe, 0, 200);
return $projectNumber . '_' . $safe;
}
/**
* Resolve a project sub-path to a safe absolute path
* Returns null if path traversal detected
*/
private function resolveProjectPath(string $projectNumber, string $subPath): ?string
{
$folderPath = $this->findProjectFolder($projectNumber);
if ($folderPath === null) {
return null;
}
if ($subPath === '' || $subPath === '/') {
return $folderPath;
}
// Zakladni path traversal ochrana
if (str_contains($subPath, "\0") || str_contains($subPath, '..')) {
return null;
}
$subPath = str_replace('\\', '/', $subPath);
$subPath = trim($subPath, '/');
$candidate = $folderPath . '/' . $subPath;
$normalBase = str_replace('\\', '/', $folderPath);
// realpath kontrola - soubor/slozka musi existovat pro existujici cesty
if (file_exists($candidate)) {
$real = realpath($candidate);
$normalReal = str_replace('\\', '/', (string) $real);
if ($real === false || !str_starts_with($normalReal, $normalBase)) {
return null;
}
return $normalReal;
}
// Pro nove soubory/slozky - kontrola rodice
$parentDir = dirname($candidate);
if (file_exists($parentDir)) {
$realParent = realpath($parentDir);
$normalParent = str_replace('\\', '/', (string) $realParent);
if ($realParent === false || !str_starts_with($normalParent, $normalBase)) {
return null;
}
}
return $candidate;
}
private function deleteRecursive(string $path): bool
{
if (is_file($path)) {
return unlink($path);
}
$entries = scandir($path);
if ($entries === false) {
return false;
}
foreach ($entries as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
$fullPath = $path . '/' . $entry;
if (!$this->deleteRecursive($fullPath)) {
return false;
}
}
return rmdir($path);
}
private function countItems(string $dirPath): int
{
$entries = scandir($dirPath);
if ($entries === false) {
return 0;
}
return max(0, count($entries) - 2);
}
public function sanitizeFilename(string $name): string
{
$name = basename($name);
$name = preg_replace('/[\x00-\x1f\x7f<>:"\/\\\\|?*]/', '', $name) ?? '';
$name = trim($name, '. ');
if (mb_strlen($name) > 255) {
$ext = pathinfo($name, PATHINFO_EXTENSION);
$base = mb_substr(pathinfo($name, PATHINFO_FILENAME), 0, 250 - mb_strlen($ext));
$name = $ext ? $base . '.' . $ext : $base;
}
return $name;
}
public function formatFileSize(int $bytes): string
{
if ($bytes < 1024) {
return $bytes . ' B';
}
if ($bytes < 1048576) {
return round($bytes / 1024, 1) . ' KB';
}
if ($bytes < 1073741824) {
return round($bytes / 1048576, 1) . ' MB';
}
return round($bytes / 1073741824, 1) . ' GB';
}
/**
* Detekce podezrelych MIME typů (napr. exe maskujici se jako jpg)
*/
private function isSuspiciousMime(string $mime, string $ext): bool
{
$executableMimes = [
'application/x-executable',
'application/x-msdos-program',
'application/x-dosexec',
'application/x-msdownload',
];
if (in_array($mime, $executableMimes, true)) {
return true;
}
// PHP soubory
if (str_contains($mime, 'php') || str_contains($mime, 'x-httpd')) {
return true;
}
return false;
}
}

View File

@@ -36,3 +36,12 @@ define('INCLUDES_PATH', API_ROOT . '/includes');
// Rate limiting // Rate limiting
define('RATE_LIMIT_STORAGE_PATH', dirname(__DIR__) . '/rate_limits'); define('RATE_LIMIT_STORAGE_PATH', dirname(__DIR__) . '/rate_limits');
// NAS File Manager
define('NAS_FILES_PATH', env('NAS_FILES_PATH', ''));
define('NAS_MAX_UPLOAD_SIZE', (int) env('NAS_MAX_UPLOAD_SIZE', 52428800));
define(
'NAS_ALLOWED_EXTENSIONS',
array_filter(array_map('trim', explode(',', (string) env('NAS_ALLOWED_EXTENSIONS', ''))))
);
define('NAS_BLOCKED_EXTENSIONS', ['exe', 'bat', 'sh', 'php', 'htaccess', 'env', 'cmd', 'com', 'msi', 'ps1']);

View File

@@ -1 +0,0 @@
{"window_start":1773394755,"count":1}

View File

@@ -1 +1 @@
{"window_start":1773397817,"count":1} {"window_start":1773403455,"count":8}

View File

@@ -1 +0,0 @@
{"window_start":1773399442,"count":1}

View File

@@ -1 +0,0 @@
{"window_start":1773394748,"count":1}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,4 +1,4 @@
import{j as e,m as f}from"./vendor-animation-0s3FMHwK.js";import{r as m}from"./vendor-react-BVs3cwbi.js";import{a9 as T}from"./vendor-utils-Dyr8OjFr.js";import{a as C,u as A,c as O,F as B,A as H}from"./index-CNxd7jIT.js";import{F as I}from"./Forbidden-D25jV3Oq.js";import{c as W,b as k,g as w,d as z,e as S,a as v,h as E,i as y,f as b}from"./attendanceHelpers-D6sLEw0q.js";const L="/api/admin",R=s=>s.break_start&&s.break_end?`${b(s.break_start)} - ${b(s.break_end)}`:s.break_start?`${b(s.break_start)} - ?`:"—",Z=s=>s.project_logs&&s.project_logs.length>0?e.jsx("div",{style:{display:"flex",flexDirection:"column",gap:"0.125rem"},children:s.project_logs.map((n,g)=>{let d,c,o=!1;if(n.hours!==null&&n.hours!==void 0)d=parseInt(n.hours)||0,c=parseInt(n.minutes)||0;else{o=!n.ended_at;const x=n.ended_at?new Date(n.ended_at):new Date,p=Math.floor((x-new Date(n.started_at))/6e4);d=Math.floor(p/60),c=p%60}return e.jsxs("span",{className:"admin-badge",style:{fontSize:"0.7rem",display:"inline-block",background:o?"var(--accent-light)":void 0},children:[n.project_name||`#${n.project_id}`," (",d,":",String(c).padStart(2,"0"),"h",o?" ▸":"",")"]},n.id||g)})}):s.project_name?e.jsx("span",{className:"admin-badge admin-badge-wrap",style:{fontSize:"0.75rem"},children:s.project_name}):"—",Y=s=>s.overtime>0?e.jsxs("span",{className:"leave-badge badge-overtime",children:["+",s.overtime,"h přesčas"]}):s.remaining>0?e.jsxs("span",{style:{color:"#dc2626"},children:["",s.remaining,"h"]}):e.jsx("span",{style:{color:"#16a34a"},children:"splněno"});function Q(){const s=C(),{user:n,hasPermission:g}=A(),[d,c]=m.useState(!0),o=m.useRef(null),[x,p]=m.useState(()=>{const a=new Date;return`${a.getFullYear()}-${String(a.getMonth()+1).padStart(2,"0")}`}),[t,D]=m.useState({records:[],month_name:"",year:new Date().getFullYear(),total_minutes:0,vacation_hours:0,sick_hours:0,holiday_hours:0,unpaid_hours:0,leave_balance:null,monthly_fund:null}),_=m.useCallback(async()=>{c(!0);try{const a=await O(`${L}/attendance.php?action=history&month=${x}`);if(a.status===401)return;const i=await a.json();i.success&&D(i.data)}catch{s.error("Nepodařilo se načíst data")}finally{c(!1)}},[x,s]);if(m.useEffect(()=>{_()},[_]),!g("attendance.history"))return e.jsx(I,{});const $=()=>{if(!o.current)return;const a=window.open("","_blank");a.document.write(` import{j as e,m as f}from"./vendor-animation-0s3FMHwK.js";import{r as m}from"./vendor-react-BVs3cwbi.js";import{a9 as T}from"./vendor-utils-Dyr8OjFr.js";import{a as C,u as A,c as O,F as B,A as H}from"./index-BrM8fzBu.js";import{F as I}from"./Forbidden-D25jV3Oq.js";import{c as W,b as k,g as w,d as z,e as S,a as v,h as E,i as y,f as b}from"./attendanceHelpers-D6sLEw0q.js";const L="/api/admin",R=s=>s.break_start&&s.break_end?`${b(s.break_start)} - ${b(s.break_end)}`:s.break_start?`${b(s.break_start)} - ?`:"—",Z=s=>s.project_logs&&s.project_logs.length>0?e.jsx("div",{style:{display:"flex",flexDirection:"column",gap:"0.125rem"},children:s.project_logs.map((n,g)=>{let d,c,o=!1;if(n.hours!==null&&n.hours!==void 0)d=parseInt(n.hours)||0,c=parseInt(n.minutes)||0;else{o=!n.ended_at;const x=n.ended_at?new Date(n.ended_at):new Date,p=Math.floor((x-new Date(n.started_at))/6e4);d=Math.floor(p/60),c=p%60}return e.jsxs("span",{className:"admin-badge",style:{fontSize:"0.7rem",display:"inline-block",background:o?"var(--accent-light)":void 0},children:[n.project_name||`#${n.project_id}`," (",d,":",String(c).padStart(2,"0"),"h",o?" ▸":"",")"]},n.id||g)})}):s.project_name?e.jsx("span",{className:"admin-badge admin-badge-wrap",style:{fontSize:"0.75rem"},children:s.project_name}):"—",Y=s=>s.overtime>0?e.jsxs("span",{className:"leave-badge badge-overtime",children:["+",s.overtime,"h přesčas"]}):s.remaining>0?e.jsxs("span",{style:{color:"#dc2626"},children:["",s.remaining,"h"]}):e.jsx("span",{style:{color:"#16a34a"},children:"splněno"});function Q(){const s=C(),{user:n,hasPermission:g}=A(),[d,c]=m.useState(!0),o=m.useRef(null),[x,p]=m.useState(()=>{const a=new Date;return`${a.getFullYear()}-${String(a.getMonth()+1).padStart(2,"0")}`}),[t,D]=m.useState({records:[],month_name:"",year:new Date().getFullYear(),total_minutes:0,vacation_hours:0,sick_hours:0,holiday_hours:0,unpaid_hours:0,leave_balance:null,monthly_fund:null}),_=m.useCallback(async()=>{c(!0);try{const a=await O(`${L}/attendance.php?action=history&month=${x}`);if(a.status===401)return;const i=await a.json();i.success&&D(i.data)}catch{s.error("Nepodařilo se načíst data")}finally{c(!1)}},[x,s]);if(m.useEffect(()=>{_()},[_]),!g("attendance.history"))return e.jsx(I,{});const $=()=>{if(!o.current)return;const a=window.open("","_blank");a.document.write(`
<!DOCTYPE html> <!DOCTYPE html>
<html lang="cs"> <html lang="cs">
<head> <head>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
dist/assets/OrderDetail-CV53xEih.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
dist/assets/Orders-CtS3KkKW.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
dist/assets/ProjectDetail-TbZLFSAA.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/Projects-DvLHy4pA.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,4 +1,4 @@
import{j as e,m as p,A as Z}from"./vendor-animation-0s3FMHwK.js";import{r as i,L as J}from"./vendor-react-BVs3cwbi.js";import{a9 as G}from"./vendor-utils-Dyr8OjFr.js";import{a as q,u as Q,c as b,b as X,F as r,A as C,f as l,C as ee}from"./index-CNxd7jIT.js";import{F as se}from"./Forbidden-D25jV3Oq.js";import{b as $}from"./attendanceHelpers-D6sLEw0q.js";const N="/api/admin";function de(){const d=q(),{hasPermission:L}=Q(),[k,D]=i.useState(!0),[j,V]=i.useState(()=>{const s=new Date;return`${s.getFullYear()}-${String(s.getMonth()+1).padStart(2,"0")}-01`}),[g,A]=i.useState(()=>{const s=new Date,t=new Date(s.getFullYear(),s.getMonth()+1,0).getDate();return`${s.getFullYear()}-${String(s.getMonth()+1).padStart(2,"0")}-${String(t).padStart(2,"0")}`}),[m,F]=i.useState(""),[h,E]=i.useState(""),[P,B]=i.useState({trips:[],vehicles:[],users:[],totals:{total:0,business:0,count:0}}),[n,I]=i.useState(null),w=i.useRef(null),[T,v]=i.useState(!1),[_,U]=i.useState(null),[a,o]=i.useState({vehicle_id:"",trip_date:"",start_km:"",end_km:"",route_from:"",route_to:"",is_business:1,notes:""}),[u,z]=i.useState({show:!1,trip:null}),y=i.useCallback(async(s=!0)=>{s&&D(!0);try{let t=`${N}/trips.php?action=admin&date_from=${j}&date_to=${g}`;m&&(t+=`&vehicle_id=${m}`),h&&(t+=`&user_id=${h}`);const c=await(await b(t)).json();c.success&&B(c.data)}catch{d.error("Nepodařilo se načíst data")}finally{s&&D(!1)}},[j,g,m,h,d]);if(i.useEffect(()=>{y()},[y]),X(T),!L("trips.admin"))return e.jsx(se,{});const H=s=>{U(s),o({vehicle_id:s.vehicle_id,trip_date:s.trip_date,start_km:s.start_km,end_km:s.end_km,route_from:s.route_from,route_to:s.route_to,is_business:s.is_business,notes:s.notes||""}),v(!0)},O=async()=>{if(parseInt(a.end_km)<=parseInt(a.start_km)){d.error("Konečný stav km musí být větší než počáteční");return}try{const t=await(await b(`${N}/trips.php?id=${_.id}`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify(a)})).json();t.success?(v(!1),await y(!1),await new Promise(x=>setTimeout(x,300)),d.success(t.message)):d.error(t.error)}catch{d.error("Chyba připojení")}},W=async()=>{if(u.trip)try{const t=await(await b(`${N}/trips.php?id=${u.trip.id}`,{method:"DELETE"})).json();t.success?(z({show:!1,trip:null}),await y(!1),d.success(t.message)):d.error(t.error)}catch{d.error("Chyba připojení")}},K=async()=>{try{let s=`${N}/trips.php?action=print&date_from=${j}&date_to=${g}`;m&&(s+=`&vehicle_id=${m}`),h&&(s+=`&user_id=${h}`);const x=await(await b(s)).json();x.success&&(I(x.data),setTimeout(()=>{if(w.current){const c=window.open("","_blank");c.document.write(` import{j as e,m as p,A as Z}from"./vendor-animation-0s3FMHwK.js";import{r as i,L as J}from"./vendor-react-BVs3cwbi.js";import{a9 as G}from"./vendor-utils-Dyr8OjFr.js";import{a as q,u as Q,c as b,b as X,F as r,A as C,f as l,C as ee}from"./index-BrM8fzBu.js";import{F as se}from"./Forbidden-D25jV3Oq.js";import{b as $}from"./attendanceHelpers-D6sLEw0q.js";const N="/api/admin";function de(){const d=q(),{hasPermission:L}=Q(),[k,D]=i.useState(!0),[j,V]=i.useState(()=>{const s=new Date;return`${s.getFullYear()}-${String(s.getMonth()+1).padStart(2,"0")}-01`}),[g,A]=i.useState(()=>{const s=new Date,t=new Date(s.getFullYear(),s.getMonth()+1,0).getDate();return`${s.getFullYear()}-${String(s.getMonth()+1).padStart(2,"0")}-${String(t).padStart(2,"0")}`}),[m,F]=i.useState(""),[h,E]=i.useState(""),[P,B]=i.useState({trips:[],vehicles:[],users:[],totals:{total:0,business:0,count:0}}),[n,I]=i.useState(null),w=i.useRef(null),[T,v]=i.useState(!1),[_,U]=i.useState(null),[a,o]=i.useState({vehicle_id:"",trip_date:"",start_km:"",end_km:"",route_from:"",route_to:"",is_business:1,notes:""}),[u,z]=i.useState({show:!1,trip:null}),y=i.useCallback(async(s=!0)=>{s&&D(!0);try{let t=`${N}/trips.php?action=admin&date_from=${j}&date_to=${g}`;m&&(t+=`&vehicle_id=${m}`),h&&(t+=`&user_id=${h}`);const c=await(await b(t)).json();c.success&&B(c.data)}catch{d.error("Nepodařilo se načíst data")}finally{s&&D(!1)}},[j,g,m,h,d]);if(i.useEffect(()=>{y()},[y]),X(T),!L("trips.admin"))return e.jsx(se,{});const H=s=>{U(s),o({vehicle_id:s.vehicle_id,trip_date:s.trip_date,start_km:s.start_km,end_km:s.end_km,route_from:s.route_from,route_to:s.route_to,is_business:s.is_business,notes:s.notes||""}),v(!0)},O=async()=>{if(parseInt(a.end_km)<=parseInt(a.start_km)){d.error("Konečný stav km musí být větší než počáteční");return}try{const t=await(await b(`${N}/trips.php?id=${_.id}`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify(a)})).json();t.success?(v(!1),await y(!1),await new Promise(x=>setTimeout(x,300)),d.success(t.message)):d.error(t.error)}catch{d.error("Chyba připojení")}},W=async()=>{if(u.trip)try{const t=await(await b(`${N}/trips.php?id=${u.trip.id}`,{method:"DELETE"})).json();t.success?(z({show:!1,trip:null}),await y(!1),d.success(t.message)):d.error(t.error)}catch{d.error("Chyba připojení")}},K=async()=>{try{let s=`${N}/trips.php?action=print&date_from=${j}&date_to=${g}`;m&&(s+=`&vehicle_id=${m}`),h&&(s+=`&user_id=${h}`);const x=await(await b(s)).json();x.success&&(I(x.data),setTimeout(()=>{if(w.current){const c=window.open("","_blank");c.document.write(`
<!DOCTYPE html> <!DOCTYPE html>
<html lang="cs"> <html lang="cs">
<head> <head>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
dist/assets/index-S7b0Xjr1.css vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
import{j as x}from"./vendor-animation-0s3FMHwK.js";import{r as t}from"./vendor-react-BVs3cwbi.js";import{a as L,c as O}from"./index-CNxd7jIT.js";function J({column:e,sort:r,order:n}){return r!==e?null:x.jsx("svg",{width:"12",height:"12",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",style:{marginLeft:4,verticalAlign:"middle"},children:x.jsx("path",{d:n==="ASC"?"M18 15l-6-6-6 6":"M6 9l6 6 6-6"})})}function V(e,r="DESC"){const[n,a]=t.useState(e),[o,c]=t.useState(r),i=t.useRef(!1),S=t.useCallback(u=>{i.current=!0,a(m=>m===u?(c(h=>h==="ASC"?"DESC":"ASC"),m):(c("DESC"),u))},[]),d=i.current?n:null;return{sort:n,order:o,handleSort:S,activeSort:d}}function I(e,r=300){const[n,a]=t.useState(e);return t.useEffect(()=>{const o=setTimeout(()=>a(e),r);return()=>clearTimeout(o)},[e,r]),n}const N="/api/admin";function _(e,{dataKey:r,search:n,sort:a,order:o,page:c,perPage:i,extraParams:S,errorMsg:d="Nepodařilo se načíst data"}={}){const u=L(),[m,h]=t.useState([]),[j,D]=t.useState(!0),[w,k]=t.useState(null),l=t.useRef(null),p=S?JSON.stringify(S):"",b=I(n,300),C=t.useCallback(async()=>{l.current&&l.current.abort();const g=new AbortController;l.current=g;try{const s=new URLSearchParams;if(b&&s.set("search",b),a&&s.set("sort",a),o&&s.set("order",o),c&&s.set("page",c),i&&s.set("per_page",i),p){const R=JSON.parse(p);Object.entries(R).forEach(([y,A])=>{A&&s.set(y,A)})}const E=await O(`${N}/${e}?${s}`,{signal:g.signal});if(E.status===401)return;const f=await E.json();f.success?(h(f.data[r]||[]),f.data.pagination&&k(f.data.pagination)):u.error(f.error||d)}catch(s){if(s.name==="AbortError")return;u.error("Chyba připojení")}finally{D(!1)}},[u,e,r,b,a,o,c,i,p,d]);return t.useEffect(()=>(C(),()=>{l.current&&l.current.abort()}),[C]),{items:m,setItems:h,loading:j,pagination:w,refetch:C}}export{J as S,_ as a,V as u}; import{j as x}from"./vendor-animation-0s3FMHwK.js";import{r as t}from"./vendor-react-BVs3cwbi.js";import{a as L,c as O}from"./index-BrM8fzBu.js";function J({column:e,sort:r,order:n}){return r!==e?null:x.jsx("svg",{width:"12",height:"12",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",style:{marginLeft:4,verticalAlign:"middle"},children:x.jsx("path",{d:n==="ASC"?"M18 15l-6-6-6 6":"M6 9l6 6 6-6"})})}function V(e,r="DESC"){const[n,a]=t.useState(e),[o,c]=t.useState(r),i=t.useRef(!1),S=t.useCallback(u=>{i.current=!0,a(m=>m===u?(c(h=>h==="ASC"?"DESC":"ASC"),m):(c("DESC"),u))},[]),d=i.current?n:null;return{sort:n,order:o,handleSort:S,activeSort:d}}function I(e,r=300){const[n,a]=t.useState(e);return t.useEffect(()=>{const o=setTimeout(()=>a(e),r);return()=>clearTimeout(o)},[e,r]),n}const N="/api/admin";function _(e,{dataKey:r,search:n,sort:a,order:o,page:c,perPage:i,extraParams:S,errorMsg:d="Nepodařilo se načíst data"}={}){const u=L(),[m,h]=t.useState([]),[j,D]=t.useState(!0),[w,k]=t.useState(null),l=t.useRef(null),p=S?JSON.stringify(S):"",b=I(n,300),C=t.useCallback(async()=>{l.current&&l.current.abort();const g=new AbortController;l.current=g;try{const s=new URLSearchParams;if(b&&s.set("search",b),a&&s.set("sort",a),o&&s.set("order",o),c&&s.set("page",c),i&&s.set("per_page",i),p){const R=JSON.parse(p);Object.entries(R).forEach(([y,A])=>{A&&s.set(y,A)})}const E=await O(`${N}/${e}?${s}`,{signal:g.signal});if(E.status===401)return;const f=await E.json();f.success?(h(f.data[r]||[]),f.data.pagination&&k(f.data.pagination)):u.error(f.error||d)}catch(s){if(s.name==="AbortError")return;u.error("Chyba připojení")}finally{D(!1)}},[u,e,r,b,a,o,c,i,p,d]);return t.useEffect(()=>(C(),()=>{l.current&&l.current.abort()}),[C]),{items:m,setItems:h,loading:j,pagination:w,refetch:C}}export{J as S,_ as a,V as u};

4
dist/index.html vendored
View File

@@ -29,11 +29,11 @@
<link <link
href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Plus+Jakarta+Sans:wght@400;500;600;700&family=Urbanist:wght@400;500;600;700;800&display=swap" href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Plus+Jakarta+Sans:wght@400;500;600;700&family=Urbanist:wght@400;500;600;700;800&display=swap"
rel="stylesheet" /> rel="stylesheet" />
<script type="module" crossorigin src="/assets/index-CNxd7jIT.js"></script> <script type="module" crossorigin src="/assets/index-BrM8fzBu.js"></script>
<link rel="modulepreload" crossorigin href="/assets/vendor-react-BVs3cwbi.js"> <link rel="modulepreload" crossorigin href="/assets/vendor-react-BVs3cwbi.js">
<link rel="modulepreload" crossorigin href="/assets/vendor-animation-0s3FMHwK.js"> <link rel="modulepreload" crossorigin href="/assets/vendor-animation-0s3FMHwK.js">
<link rel="modulepreload" crossorigin href="/assets/vendor-utils-Dyr8OjFr.js"> <link rel="modulepreload" crossorigin href="/assets/vendor-utils-Dyr8OjFr.js">
<link rel="stylesheet" crossorigin href="/assets/index-D_wrslmx.css"> <link rel="stylesheet" crossorigin href="/assets/index-S7b0Xjr1.css">
</head> </head>
<body style="background-color: var(--bg-primary, #12121a);"> <body style="background-color: var(--bg-primary, #12121a);">

View File

@@ -3,7 +3,7 @@
'name' => 'boha/website', 'name' => 'boha/website',
'pretty_version' => 'dev-master', 'pretty_version' => 'dev-master',
'version' => 'dev-master', 'version' => 'dev-master',
'reference' => '308941449e1b5f1ec6752c297d286e6633c11fed', 'reference' => '9e3c95e5764f4ec5167c09b9f9cf05a58891a0aa',
'type' => 'project', 'type' => 'project',
'install_path' => __DIR__ . '/../../', 'install_path' => __DIR__ . '/../../',
'aliases' => array(), 'aliases' => array(),
@@ -13,7 +13,7 @@
'boha/website' => array( 'boha/website' => array(
'pretty_version' => 'dev-master', 'pretty_version' => 'dev-master',
'version' => 'dev-master', 'version' => 'dev-master',
'reference' => '308941449e1b5f1ec6752c297d286e6633c11fed', 'reference' => '9e3c95e5764f4ec5167c09b9f9cf05a58891a0aa',
'type' => 'project', 'type' => 'project',
'install_path' => __DIR__ . '/../../', 'install_path' => __DIR__ . '/../../',
'aliases' => array(), 'aliases' => array(),

View File

@@ -0,0 +1,3 @@
-- Filemanager: permission pro spravu souboru v projektu
INSERT INTO permissions (name, display_name, description, module)
VALUES ('projects.files', 'Správa souborů', 'Nahrávání, mazání a přesouvání souborů v projektu', 'projects');

View File

@@ -713,6 +713,11 @@ img {
box-shadow: 0 4px 12px rgba(214, 48, 49, 0.3); box-shadow: 0 4px 12px rgba(214, 48, 49, 0.3);
} }
.admin-btn-primary .admin-spinner {
border-color: rgba(255, 255, 255, 0.3);
border-top-color: #fff;
}
.admin-btn-secondary { .admin-btn-secondary {
background: var(--bg-tertiary); background: var(--bg-tertiary);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
@@ -2496,3 +2501,154 @@ img {
white-space: nowrap; white-space: nowrap;
} }
/* ============================================================================
File Manager
============================================================================ */
.fm-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.75rem;
flex-wrap: wrap;
}
.fm-toolbar-actions {
display: flex;
gap: 0.5rem;
align-items: center;
}
.fm-breadcrumb {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0;
font-size: 12px;
min-height: 28px;
}
.fm-breadcrumb-segment {
display: inline-flex;
align-items: center;
}
.fm-breadcrumb-sep {
color: var(--text-tertiary);
margin: 0 4px;
user-select: none;
}
.fm-breadcrumb-btn {
background: none;
border: none;
padding: 2px 6px;
border-radius: 4px;
color: var(--text-secondary);
cursor: pointer;
font-family: var(--font-mono);
font-size: 12px;
transition: all 0.15s ease;
}
.fm-breadcrumb-btn:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.fm-breadcrumb-btn.active {
color: var(--text-primary);
font-weight: 600;
}
.fm-new-folder {
display: flex;
gap: 0.5rem;
align-items: center;
margin-bottom: 0.75rem;
}
.fm-new-folder .admin-form-input {
max-width: 250px;
}
.fm-content {
position: relative;
border-radius: var(--border-radius-sm);
transition: border-color 0.2s ease;
}
.fm-content.fm-drag-over {
border: 2px dashed var(--accent-color);
background: var(--accent-light);
}
.fm-dropzone-overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
background: color-mix(in srgb, var(--bg-primary) 90%, transparent);
border-radius: var(--border-radius-sm);
z-index: 5;
color: var(--accent-color);
font-size: 13px;
font-weight: 500;
pointer-events: none;
}
.fm-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 2.5rem 1rem;
color: var(--text-tertiary);
font-size: 13px;
}
.fm-folder-link {
background: none;
border: none;
padding: 0;
color: var(--accent-color);
font-weight: 500;
font-size: inherit;
font-family: inherit;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
}
.fm-folder-link:hover {
text-decoration: underline;
}
.fm-item-count {
font-size: 10px;
color: var(--text-tertiary);
font-weight: 400;
}
.fm-file-name {
color: var(--text-primary);
}
.fm-meta {
color: var(--text-secondary);
font-family: var(--font-mono);
font-size: 11px;
}
.fm-actions {
display: inline-flex;
gap: 2px;
justify-content: flex-end;
}

View File

@@ -87,7 +87,7 @@ export default function ConfirmModal({
{icons[type]} {icons[type]}
</div> </div>
<h2 id="confirm-modal-title" className="admin-confirm-title">{title}</h2> <h2 id="confirm-modal-title" className="admin-confirm-title">{title}</h2>
<p className="admin-confirm-message">{message}</p> <div className="admin-confirm-message">{message}</div>
</div> </div>
<div className="admin-modal-footer"> <div className="admin-modal-footer">

View File

@@ -0,0 +1,614 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { useAlert } from '../context/AlertContext'
import ConfirmModal from './ConfirmModal'
import apiFetch from '../utils/api'
const API_BASE = '/api/admin'
function getFileIcon(type, extension) {
if (type === 'folder') {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#e6a817" strokeWidth="1.5">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" fill="rgba(230, 168, 23, 0.15)" />
</svg>
)
}
const ext = (extension || '').toLowerCase()
const iconMap = {
pdf: { color: '#e74c3c', path: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' },
doc: { color: '#3498db', path: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' },
docx: { color: '#3498db', path: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' },
xls: { color: '#27ae60', path: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' },
xlsx: { color: '#27ae60', path: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' },
ppt: { color: '#e67e22', path: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' },
pptx: { color: '#e67e22', path: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' },
jpg: { color: '#3498db', path: 'M19 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2z' },
jpeg: { color: '#3498db', path: 'M19 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2z' },
png: { color: '#3498db', path: 'M19 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2z' },
gif: { color: '#3498db', path: 'M19 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2z' },
zip: { color: '#e67e22', path: 'M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z' },
rar: { color: '#e67e22', path: 'M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z' },
'7z': { color: '#e67e22', path: 'M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z' },
dwg: { color: '#8e44ad', path: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' },
dxf: { color: '#8e44ad', path: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' },
step: { color: '#8e44ad', path: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' },
stp: { color: '#8e44ad', path: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' },
}
const cfg = iconMap[ext] || { color: 'var(--text-muted)', path: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' }
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke={cfg.color} strokeWidth="1.5">
<path d={cfg.path} />
<polyline points="14 2 14 8 20 8" />
</svg>
)
}
function FileNameCell({ item, onFolderClick }) {
if (item.type === 'folder') {
return (
<button
type="button"
className="fm-folder-link"
onClick={() => onFolderClick(item.name)}
>
{item.name}
{item.item_count !== undefined && (
<span className="fm-item-count">{item.item_count}</span>
)}
</button>
)
}
return <span className="fm-file-name">{item.name}</span>
}
export default function ProjectFileManager({ projectId, projectNumber, hasPermission, hasNasFolder }) {
const alert = useAlert()
const fileInputRef = useRef(null)
const [items, setItems] = useState([])
const [loading, setLoading] = useState(true)
const [currentPath, setCurrentPath] = useState('')
const [breadcrumb, setBreadcrumb] = useState([''])
const [dragOver, setDragOver] = useState(false)
const [uploading, setUploading] = useState(false)
const [newFolderMode, setNewFolderMode] = useState(false)
const [newFolderName, setNewFolderName] = useState('')
const [creatingFolder, setCreatingFolder] = useState(false)
const [renamingItem, setRenamingItem] = useState(null)
const [renameValue, setRenameValue] = useState('')
const [deleteTarget, setDeleteTarget] = useState(null)
const [deleting, setDeleting] = useState(false)
const [errorMessage, setErrorMessage] = useState(null)
const canManage = hasPermission('projects.files')
const fetchFiles = useCallback(async (path = '', options = {}) => {
setLoading(true)
setErrorMessage(null)
try {
const params = new URLSearchParams({ project_id: projectId })
if (path) {
params.set('path', path)
}
const res = await apiFetch(`${API_BASE}/project-files.php?${params}`)
if (options.ignore) return
if (res.status === 401) return
const data = await res.json()
if (data.success) {
setItems(data.data.items || [])
setBreadcrumb(data.data.breadcrumb || [''])
setCurrentPath(data.data.path || '')
} else if (res.status === 404) {
setItems([])
setBreadcrumb([''])
} else {
setErrorMessage(data.error || 'Nepodařilo se načíst soubory')
}
} catch {
if (!options.ignore) {
setErrorMessage('Chyba připojení')
}
} finally {
if (!options.ignore) {
setLoading(false)
}
}
}, [projectId])
useEffect(() => {
const opts = { ignore: false }
fetchFiles('', opts)
return () => { opts.ignore = true }
}, [fetchFiles])
const navigateTo = (path) => {
setNewFolderMode(false)
setRenamingItem(null)
fetchFiles(path)
}
const handleBreadcrumbClick = (index) => {
if (index === 0) {
navigateTo('')
return
}
const path = breadcrumb.slice(1, index + 1).join('/')
navigateTo(path)
}
const handleFolderClick = (folderName) => {
const path = currentPath ? `${currentPath}/${folderName}` : folderName
navigateTo(path)
}
const handleUpload = async (files) => {
if (!files || files.length === 0) return
setUploading(true)
let successCount = 0
let errorMsg = null
for (const file of files) {
const formData = new FormData()
formData.append('file', file)
const params = new URLSearchParams({
action: 'upload',
project_id: projectId,
})
if (currentPath) {
params.set('path', currentPath)
}
try {
const res = await apiFetch(`${API_BASE}/project-files.php?${params}`, {
method: 'POST',
body: formData,
})
const data = await res.json()
if (data.success) {
successCount++
} else {
errorMsg = data.error || 'Chyba při nahrávání'
}
} catch {
errorMsg = 'Chyba připojení'
}
}
setUploading(false)
if (successCount > 0) {
const msg = successCount === 1 ? 'Soubor byl nahrán' : `Nahráno ${successCount} souborů`
alert.success(msg)
fetchFiles(currentPath)
}
if (errorMsg) {
alert.error(errorMsg)
}
}
const handleFileInputChange = (e) => {
handleUpload(e.target.files)
e.target.value = ''
}
const handleDrop = (e) => {
e.preventDefault()
setDragOver(false)
if (!canManage) return
handleUpload(e.dataTransfer.files)
}
const handleDragOver = (e) => {
e.preventDefault()
if (canManage) {
setDragOver(true)
}
}
const handleDragLeave = (e) => {
e.preventDefault()
setDragOver(false)
}
const handleCreateFolder = async () => {
const name = newFolderName.trim()
if (!name) return
setCreatingFolder(true)
try {
const params = new URLSearchParams({
action: 'create_folder',
project_id: projectId,
})
const res = await apiFetch(`${API_BASE}/project-files.php?${params}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: currentPath, folder_name: name }),
})
const data = await res.json()
if (data.success) {
alert.success('Složka byla vytvořena')
setNewFolderMode(false)
setNewFolderName('')
fetchFiles(currentPath)
} else {
alert.error(data.error || 'Nepodařilo se vytvořit složku')
}
} catch {
alert.error('Chyba připojení')
} finally {
setCreatingFolder(false)
}
}
const handleDownload = (item) => {
const filePath = currentPath ? `${currentPath}/${item.name}` : item.name
const params = new URLSearchParams({
action: 'download',
project_id: projectId,
path: filePath,
})
window.open(`${API_BASE}/project-files.php?${params}`, '_blank')
}
const handleDelete = async () => {
if (!deleteTarget) return
setDeleting(true)
const filePath = currentPath ? `${currentPath}/${deleteTarget.name}` : deleteTarget.name
try {
const params = new URLSearchParams({
project_id: projectId,
path: filePath,
})
const res = await apiFetch(`${API_BASE}/project-files.php?${params}`, {
method: 'DELETE',
})
const data = await res.json()
if (data.success) {
alert.success(deleteTarget.type === 'folder' ? 'Složka byla smazána' : 'Soubor byl smazán')
fetchFiles(currentPath)
} else {
alert.error(data.error || 'Nepodařilo se smazat')
}
} catch {
alert.error('Chyba připojení')
} finally {
setDeleting(false)
setDeleteTarget(null)
}
}
const handleRename = async (item) => {
const newName = renameValue.trim()
if (!newName || newName === item.name) {
setRenamingItem(null)
return
}
const fromPath = currentPath ? `${currentPath}/${item.name}` : item.name
const toPath = currentPath ? `${currentPath}/${newName}` : newName
try {
const params = new URLSearchParams({
action: 'move',
project_id: projectId,
})
const res = await apiFetch(`${API_BASE}/project-files.php?${params}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ from_path: fromPath, to_path: toPath }),
})
const data = await res.json()
if (data.success) {
alert.success('Přejmenováno')
fetchFiles(currentPath)
} else {
alert.error(data.error || 'Nepodařilo se přejmenovat')
}
} catch {
alert.error('Chyba připojení')
} finally {
setRenamingItem(null)
}
}
const startRename = (item) => {
setRenamingItem(item.name)
setRenameValue(item.name)
}
if (loading && items.length === 0 && !errorMessage) {
return (
<div className="admin-card">
<div className="admin-card-body">
<h3 className="admin-card-title">Soubory</h3>
<div className="admin-skeleton" style={{ padding: 0, gap: '0.5rem' }}>
{[0, 1, 2, 3].map(i => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line" style={{ width: '18px', height: '18px', borderRadius: '4px', flexShrink: 0 }} />
<div className="admin-skeleton-line" style={{ width: `${60 + i * 10}%` }} />
</div>
))}
</div>
</div>
</div>
)
}
if (errorMessage) {
return (
<div className="admin-card">
<div className="admin-card-body">
<h3 className="admin-card-title">Soubory</h3>
<div className="fm-empty">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="1.5">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
<span>{errorMessage}</span>
</div>
</div>
</div>
)
}
return (
<div className="admin-card">
<div className="admin-card-body">
<h3 className="admin-card-title">Soubory</h3>
{/* Toolbar */}
<div className="fm-toolbar">
<div className="fm-breadcrumb">
{breadcrumb.map((segment, i) => (
<span key={i} className="fm-breadcrumb-segment">
{i > 0 && <span className="fm-breadcrumb-sep">/</span>}
<button
type="button"
className={`fm-breadcrumb-btn ${i === breadcrumb.length - 1 ? 'active' : ''}`}
onClick={() => handleBreadcrumbClick(i)}
>
{i === 0 ? projectNumber : segment}
</button>
</span>
))}
</div>
{canManage && (
<div className="fm-toolbar-actions">
<button
type="button"
className="admin-btn admin-btn-secondary admin-btn-sm"
onClick={() => {
setNewFolderMode(!newFolderMode)
setNewFolderName('')
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
<line x1="12" y1="11" x2="12" y2="17" />
<line x1="9" y1="14" x2="15" y2="14" />
</svg>
Složka
</button>
<button
type="button"
className="admin-btn admin-btn-primary admin-btn-sm"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
>
{uploading ? (
<>
<div className="admin-spinner admin-spinner-sm" />
Nahrávání...
</>
) : (
<>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
Nahrát
</>
)}
</button>
<input
ref={fileInputRef}
type="file"
multiple
style={{ display: 'none' }}
onChange={handleFileInputChange}
/>
</div>
)}
</div>
{/* New folder input */}
{newFolderMode && (
<div className="fm-new-folder">
<input
type="text"
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
className="admin-form-input"
placeholder="Název složky..."
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') handleCreateFolder()
if (e.key === 'Escape') {
setNewFolderMode(false)
setNewFolderName('')
}
}}
style={{ fontSize: '12px', padding: '6px 10px' }}
/>
<button
type="button"
className="admin-btn admin-btn-primary admin-btn-sm"
onClick={handleCreateFolder}
disabled={creatingFolder || !newFolderName.trim()}
>
{creatingFolder ? <div className="admin-spinner admin-spinner-sm" /> : 'Vytvořit'}
</button>
<button
type="button"
className="admin-btn admin-btn-secondary admin-btn-sm"
onClick={() => {
setNewFolderMode(false)
setNewFolderName('')
}}
>
Zrušit
</button>
</div>
)}
{/* Drop zone + table */}
<div
className={`fm-content ${dragOver ? 'fm-drag-over' : ''}`}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
>
{dragOver && (
<div className="fm-dropzone-overlay">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
<span>Přetáhněte soubory sem</span>
</div>
)}
{items.length === 0 && !loading ? (
<div className="fm-empty">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="1.5">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
</svg>
<span>
{hasNasFolder ? 'Složka je prázdná' : 'Složka projektu zatím neexistuje'}
</span>
{canManage && !hasNasFolder && (
<span style={{ fontSize: '11px' }}>Nahrání souboru ji automaticky vytvoří</span>
)}
</div>
) : (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th style={{ width: '30px' }}></th>
<th>Název</th>
<th style={{ width: '90px' }}>Velikost</th>
<th style={{ width: '120px' }}>Změněno</th>
{canManage && <th style={{ width: '100px', textAlign: 'right' }}>Akce</th>}
</tr>
</thead>
<tbody>
{items.map(item => (
<tr key={item.name}>
<td style={{ textAlign: 'center' }}>
{getFileIcon(item.type, item.extension)}
</td>
<td>
{renamingItem === item.name ? (
<input
type="text"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
className="admin-form-input"
style={{ fontSize: '11px', padding: '3px 8px', maxWidth: '300px' }}
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') handleRename(item)
if (e.key === 'Escape') setRenamingItem(null)
}}
onBlur={() => handleRename(item)}
/>
) : (
<FileNameCell item={item} onFolderClick={handleFolderClick} />
)}
</td>
<td className="fm-meta">
{item.type === 'file' ? item.size_formatted : '—'}
</td>
<td className="fm-meta">{item.modified || '—'}</td>
{canManage && (
<td style={{ textAlign: 'right' }}>
<div className="fm-actions">
{item.type === 'file' && (
<button
type="button"
className="admin-btn-icon"
title="Stáhnout"
onClick={() => handleDownload(item)}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
</button>
)}
<button
type="button"
className="admin-btn-icon"
title="Přejmenovat"
onClick={() => startRename(item)}
>
<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>
</button>
<button
type="button"
className="admin-btn-icon danger"
title="Smazat"
onClick={() => setDeleteTarget(item)}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6M14 11v6" />
</svg>
</button>
</div>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
<ConfirmModal
isOpen={deleteTarget !== null}
onClose={() => setDeleteTarget(null)}
onConfirm={handleDelete}
title={deleteTarget?.type === 'folder' ? 'Smazat složku' : 'Smazat soubor'}
message={`Opravdu chcete smazat "${deleteTarget?.name}"?${deleteTarget?.type === 'folder' ? ' Složka bude smazána včetně veškerého obsahu.' : ''}`}
confirmText="Smazat"
cancelText="Zrušit"
type="danger"
loading={deleting}
/>
</div>
)
}

View File

@@ -54,6 +54,7 @@ export default function OrderDetail() {
const [attachmentLoading, setAttachmentLoading] = useState(false) const [attachmentLoading, setAttachmentLoading] = useState(false)
const [deleteConfirm, setDeleteConfirm] = useState(false) const [deleteConfirm, setDeleteConfirm] = useState(false)
const [deleting, setDeleting] = useState(false) const [deleting, setDeleting] = useState(false)
const [deleteFiles, setDeleteFiles] = useState(false)
const fetchDetail = async () => { const fetchDetail = async () => {
try { try {
@@ -197,7 +198,11 @@ export default function OrderDetail() {
const handleDelete = async () => { const handleDelete = async () => {
setDeleting(true) setDeleting(true)
try { try {
const response = await apiFetch(`${API_BASE}/orders.php?id=${id}`, { method: 'DELETE' }) const response = await apiFetch(`${API_BASE}/orders.php?id=${id}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ delete_files: deleteFiles }),
})
const result = await response.json() const result = await response.json()
if (result.success) { if (result.success) {
alert.success(result.message || 'Objednávka byla smazána') alert.success(result.message || 'Objednávka byla smazána')
@@ -589,10 +594,27 @@ export default function OrderDetail() {
{/* Delete confirmation */} {/* Delete confirmation */}
<ConfirmModal <ConfirmModal
isOpen={deleteConfirm} isOpen={deleteConfirm}
onClose={() => setDeleteConfirm(false)} onClose={() => {
setDeleteConfirm(false)
setDeleteFiles(false)
}}
onConfirm={handleDelete} onConfirm={handleDelete}
title="Smazat objednávku" title="Smazat objednávku"
message={`Opravdu chcete smazat objednávku "${order.order_number}"? Bude smazán i přidružený projekt. Tato akce je nevratná.`} message={
<>
Opravdu chcete smazat objednávku &quot;{order.order_number}&quot;? Bude smazán i přidružený projekt. Tato akce je nevratná.
{order.project?.has_nas_folder && (
<label className="admin-form-checkbox" style={{ marginTop: '1rem', display: 'flex' }}>
<input
type="checkbox"
checked={deleteFiles}
onChange={(e) => setDeleteFiles(e.target.checked)}
/>
<span>Smazat i soubory projektu na disku</span>
</label>
)}
</>
}
confirmText="Smazat" confirmText="Smazat"
cancelText="Zrušit" cancelText="Zrušit"
type="danger" type="danger"

View File

@@ -38,6 +38,7 @@ export default function Orders() {
const [deleteConfirm, setDeleteConfirm] = useState({ show: false, order: null }) const [deleteConfirm, setDeleteConfirm] = useState({ show: false, order: null })
const [deleting, setDeleting] = useState(false) const [deleting, setDeleting] = useState(false)
const [deleteFiles, setDeleteFiles] = useState(false)
const { items: orders, loading, pagination, refetch: fetchData } = useListData('orders.php', { const { items: orders, loading, pagination, refetch: fetchData } = useListData('orders.php', {
dataKey: 'orders', search, sort, order, page, dataKey: 'orders', search, sort, order, page,
@@ -51,11 +52,14 @@ export default function Orders() {
setDeleting(true) setDeleting(true)
try { try {
const response = await apiFetch(`${API_BASE}/orders.php?id=${deleteConfirm.order.id}`, { const response = await apiFetch(`${API_BASE}/orders.php?id=${deleteConfirm.order.id}`, {
method: 'DELETE' method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ delete_files: deleteFiles }),
}) })
const result = await response.json() const result = await response.json()
if (result.success) { if (result.success) {
setDeleteConfirm({ show: false, order: null }) setDeleteConfirm({ show: false, order: null })
setDeleteFiles(false)
alert.success(result.message || 'Objednávka byla smazána') alert.success(result.message || 'Objednávka byla smazána')
fetchData() fetchData()
} else { } else {
@@ -272,10 +276,25 @@ export default function Orders() {
<ConfirmModal <ConfirmModal
isOpen={deleteConfirm.show} isOpen={deleteConfirm.show}
onClose={() => setDeleteConfirm({ show: false, order: null })} onClose={() => {
setDeleteConfirm({ show: false, order: null })
setDeleteFiles(false)
}}
onConfirm={handleDelete} onConfirm={handleDelete}
title="Smazat objednávku" title="Smazat objednávku"
message={`Opravdu chcete smazat objednávku "${deleteConfirm.order?.order_number}"? Bude smazán i přidružený projekt. Tato akce je nevratná.`} message={
<>
Opravdu chcete smazat objednávku &quot;{deleteConfirm.order?.order_number}&quot;? Bude smazán i přidružený projekt. Tato akce je nevratná.
<label className="admin-form-checkbox" style={{ marginTop: '1rem', display: 'flex' }}>
<input
type="checkbox"
checked={deleteFiles}
onChange={(e) => setDeleteFiles(e.target.checked)}
/>
<span>Smazat i soubory projektu na disku</span>
</label>
</>
}
confirmText="Smazat" confirmText="Smazat"
cancelText="Zrušit" cancelText="Zrušit"
type="danger" type="danger"

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react' import { useState, useEffect, useRef } from 'react'
import { useAlert } from '../context/AlertContext' import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import { useParams, useNavigate, useLocation, Link } from 'react-router-dom' import { useParams, useNavigate, useLocation, Link } from 'react-router-dom'
@@ -8,6 +8,7 @@ import Forbidden from '../components/Forbidden'
import ConfirmModal from '../components/ConfirmModal' import ConfirmModal from '../components/ConfirmModal'
import FormField from '../components/FormField' import FormField from '../components/FormField'
import AdminDatePicker from '../components/AdminDatePicker' import AdminDatePicker from '../components/AdminDatePicker'
import ProjectFileManager from '../components/ProjectFileManager'
import apiFetch from '../utils/api' import apiFetch from '../utils/api'
const API_BASE = '/api/admin' const API_BASE = '/api/admin'
@@ -49,6 +50,7 @@ export default function ProjectDetail() {
const [deleteConfirm, setDeleteConfirm] = useState(false) const [deleteConfirm, setDeleteConfirm] = useState(false)
const [deleting, setDeleting] = useState(false) const [deleting, setDeleting] = useState(false)
const [deleteFiles, setDeleteFiles] = useState(false)
// Dynamic notes // Dynamic notes
const [notes, setNotes] = useState([]) const [notes, setNotes] = useState([])
@@ -57,10 +59,11 @@ export default function ProjectDetail() {
const [addingNote, setAddingNote] = useState(false) const [addingNote, setAddingNote] = useState(false)
const [deletingNoteId, setDeletingNoteId] = useState(null) const [deletingNoteId, setDeletingNoteId] = useState(null)
const createdShown = useRef(false)
useEffect(() => { useEffect(() => {
if (location.state?.created) { if (location.state?.created && !createdShown.current) {
createdShown.current = true
alert.success('Projekt byl vytvořen') alert.success('Projekt byl vytvořen')
// Clear state so it doesn't re-show on refresh
navigate(location.pathname, { replace: true, state: {} }) navigate(location.pathname, { replace: true, state: {} })
} }
}, [location.state]) // eslint-disable-line react-hooks/exhaustive-deps }, [location.state]) // eslint-disable-line react-hooks/exhaustive-deps
@@ -164,7 +167,11 @@ export default function ProjectDetail() {
const handleDelete = async () => { const handleDelete = async () => {
setDeleting(true) setDeleting(true)
try { try {
const response = await apiFetch(`${API_BASE}/projects.php?id=${id}`, { method: 'DELETE' }) const response = await apiFetch(`${API_BASE}/projects.php?id=${id}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ delete_files: deleteFiles }),
})
const result = await response.json() const result = await response.json()
if (result.success) { if (result.success) {
navigate('/projects') navigate('/projects')
@@ -523,12 +530,27 @@ export default function ProjectDetail() {
</div> </div>
</motion.div> </motion.div>
{/* Files */}
<motion.div
style={{ marginBottom: '1rem' }}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.2 }}
>
<ProjectFileManager
projectId={id}
projectNumber={project.project_number}
hasPermission={hasPermission}
hasNasFolder={project.has_nas_folder}
/>
</motion.div>
{/* Links */} {/* Links */}
<motion.div <motion.div
className="admin-card" className="admin-card"
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.2 }} transition={{ duration: 0.4, delay: 0.25 }}
> >
<div className="admin-card-body"> <div className="admin-card-body">
<h3 className="admin-card-title">Propojení</h3> <h3 className="admin-card-title">Propojení</h3>
@@ -562,10 +584,27 @@ export default function ProjectDetail() {
<ConfirmModal <ConfirmModal
isOpen={deleteConfirm} isOpen={deleteConfirm}
onClose={() => setDeleteConfirm(false)} onClose={() => {
setDeleteConfirm(false)
setDeleteFiles(false)
}}
onConfirm={handleDelete} onConfirm={handleDelete}
title="Smazat projekt" title="Smazat projekt"
message={`Opravdu chcete smazat projekt "${project.project_number} ${project.name}"? Tato akce je nevratná.`} message={
<>
Opravdu chcete smazat projekt &quot;{project.project_number} {project.name}&quot;? Tato akce je nevratná.
{project.has_nas_folder && (
<label className="admin-form-checkbox" style={{ marginTop: '1rem', display: 'flex' }}>
<input
type="checkbox"
checked={deleteFiles}
onChange={(e) => setDeleteFiles(e.target.checked)}
/>
<span>Smazat i soubory na disku</span>
</label>
)}
</>
}
confirmText="Smazat" confirmText="Smazat"
cancelText="Zrušit" cancelText="Zrušit"
type="danger" type="danger"

View File

@@ -35,6 +35,7 @@ export default function Projects() {
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [deletingId, setDeletingId] = useState(null) const [deletingId, setDeletingId] = useState(null)
const [deleteTarget, setDeleteTarget] = useState(null) const [deleteTarget, setDeleteTarget] = useState(null)
const [deleteFiles, setDeleteFiles] = useState(false)
const { items: projects, setItems: setProjects, loading, pagination } = useListData('projects.php', { const { items: projects, setItems: setProjects, loading, pagination } = useListData('projects.php', {
dataKey: 'projects', search, sort, order, page, dataKey: 'projects', search, sort, order, page,
@@ -47,7 +48,11 @@ export default function Projects() {
if (!deleteTarget) return if (!deleteTarget) return
setDeletingId(deleteTarget.id) setDeletingId(deleteTarget.id)
try { try {
const res = await apiFetch(`${API_BASE}/projects.php?id=${deleteTarget.id}`, { method: 'DELETE' }) const res = await apiFetch(`${API_BASE}/projects.php?id=${deleteTarget.id}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ delete_files: deleteFiles }),
})
const data = await res.json() const data = await res.json()
if (data.success) { if (data.success) {
alert.success(data.message || 'Projekt byl smazán') alert.success(data.message || 'Projekt byl smazán')
@@ -60,6 +65,7 @@ export default function Projects() {
} finally { } finally {
setDeletingId(null) setDeletingId(null)
setDeleteTarget(null) setDeleteTarget(null)
setDeleteFiles(false)
} }
} }
@@ -268,10 +274,25 @@ export default function Projects() {
<ConfirmModal <ConfirmModal
isOpen={!!deleteTarget} isOpen={!!deleteTarget}
onClose={() => setDeleteTarget(null)} onClose={() => {
setDeleteTarget(null)
setDeleteFiles(false)
}}
onConfirm={handleDelete} onConfirm={handleDelete}
title="Smazat projekt" title="Smazat projekt"
message={`Opravdu chcete smazat projekt ${deleteTarget?.project_number}?`} message={
<>
Opravdu chcete smazat projekt {deleteTarget?.project_number}?
<label className="admin-form-checkbox" style={{ marginTop: '1rem', display: 'flex' }}>
<input
type="checkbox"
checked={deleteFiles}
onChange={(e) => setDeleteFiles(e.target.checked)}
/>
<span>Smazat i soubory na disku</span>
</label>
</>
}
confirmText="Smazat" confirmText="Smazat"
type="danger" type="danger"
loading={!!deletingId} loading={!!deletingId}