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

View File

@@ -128,6 +128,11 @@ function handleGetDetail(PDO $pdo, int $id): void
$stmt->execute([$id]);
$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
$stmt = $pdo->prepare('SELECT id, invoice_number, status FROM invoices WHERE order_id = ? LIMIT 1');
$stmt->execute([$id]);
@@ -352,6 +357,10 @@ function handleCreateOrder(PDO $pdo): void
$pdo->commit();
$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, [
'order_number' => $orderNumber,
'quotation_number' => $quotation['quotation_number'],
@@ -482,6 +491,9 @@ function handleUpdateOrder(PDO $pdo, int $id): void
function handleDeleteOrder(PDO $pdo, int $id): void
{
$input = getJsonInput();
$deleteFiles = (bool) ($input['delete_files'] ?? false);
$stmt = $pdo->prepare(
'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);
}
// 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();
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
$stmt = $pdo->prepare('DELETE FROM projects WHERE order_id = ?');
$stmt->execute([$id]);
@@ -515,10 +540,16 @@ function handleDeleteOrder(PDO $pdo, int $id): void
$pdo->commit();
// Smazat NAS slozku pokud pozadovano
if ($deleteFiles && $project) {
$fm = new NasFileManager();
$fm->deleteProjectFolder($project['project_number']);
}
AuditLog::logDelete('orders_order', $id, [
'order_number' => $order['order_number'],
'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');
} 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,
], "Ručně vytvořen projekt '$projectNumber'");
// Vytvorit slozku na NAS
$fm = new NasFileManager();
$fm->createProjectFolder($projectNumber, $name);
successResponse([
'project_id' => $projectId,
'project_number' => $projectNumber,
@@ -134,6 +138,9 @@ function handleCreateProject(PDO $pdo): void
function handleDeleteProject(PDO $pdo, int $id): void
{
$input = getJsonInput();
$deleteFiles = (bool) ($input['delete_files'] ?? false);
$stmt = $pdo->prepare(
'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();
// Smazat slozku na NAS pokud pozadovano
if ($deleteFiles) {
$fm = new NasFileManager();
$fm->deleteProjectFolder($project['project_number']);
}
AuditLog::logUpdate(
'projects_project',
$id,
['status' => $project['status']],
['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');
@@ -254,6 +267,10 @@ function handleGetDetail(PDO $pdo, int $id): void
errorResponse('Projekt nebyl nalezen', 404);
}
// Kontrola existence slozky na NAS
$fm = new NasFileManager();
$project['has_nas_folder'] = $fm->projectFolderExists($project['project_number']);
successResponse($project);
}
@@ -344,6 +361,12 @@ function handleUpdateProject(PDO $pdo, int $id): void
$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(
'projects_project',
$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/AuditLog.php';
require_once dirname(__DIR__) . '/includes/PaginationHelper.php';
require_once dirname(__DIR__) . '/includes/NasFileManager.php';
require_once __DIR__ . '/handlers/orders-handlers.php';
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/AuditLog.php';
require_once dirname(__DIR__) . '/includes/PaginationHelper.php';
require_once dirname(__DIR__) . '/includes/NasFileManager.php';
require_once __DIR__ . '/handlers/projects-handlers.php';
setCorsHeaders();