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:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
40
CLAUDE.md
40
CLAUDE.md
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
236
api/admin/handlers/project-files-handlers.php
Normal file
236
api/admin/handlers/project-files-handlers.php
Normal 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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
api/admin/project-files.php
Normal file
81
api/admin/project-files.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
api/includes/NasFileManager.php
Normal file
521
api/includes/NasFileManager.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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']);
|
||||||
|
|||||||
33
dist/api/admin/handlers/orders-handlers.php
vendored
33
dist/api/admin/handlers/orders-handlers.php
vendored
@@ -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) {
|
||||||
|
|||||||
236
dist/api/admin/handlers/project-files-handlers.php
vendored
Normal file
236
dist/api/admin/handlers/project-files-handlers.php
vendored
Normal 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;
|
||||||
|
}
|
||||||
25
dist/api/admin/handlers/projects-handlers.php
vendored
25
dist/api/admin/handlers/projects-handlers.php
vendored
@@ -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,
|
||||||
|
|||||||
1
dist/api/admin/orders.php
vendored
1
dist/api/admin/orders.php
vendored
@@ -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
81
dist/api/admin/project-files.php
vendored
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
1
dist/api/admin/projects.php
vendored
1
dist/api/admin/projects.php
vendored
@@ -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
521
dist/api/includes/NasFileManager.php
vendored
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
9
dist/api/includes/constants.php
vendored
9
dist/api/includes/constants.php
vendored
@@ -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']);
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
{"window_start":1773394755,"count":1}
|
|
||||||
@@ -1 +1 @@
|
|||||||
{"window_start":1773397817,"count":1}
|
{"window_start":1773403455,"count":8}
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"window_start":1773399442,"count":1}
|
|
||||||
@@ -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
@@ -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
1
dist/assets/OrderDetail-CV53xEih.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/assets/OrderDetail-DgTv224i.js
vendored
1
dist/assets/OrderDetail-DgTv224i.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/Orders-Bm_dTJbR.js
vendored
1
dist/assets/Orders-Bm_dTJbR.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/Orders-CtS3KkKW.js
vendored
Normal file
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
1
dist/assets/ProjectDetail-Dg0G_KTk.js
vendored
1
dist/assets/ProjectDetail-Dg0G_KTk.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/ProjectDetail-TbZLFSAA.js
vendored
Normal file
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
1
dist/assets/Projects-DvLHy4pA.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/assets/Projects-Pu7lx6LE.js
vendored
1
dist/assets/Projects-Pu7lx6LE.js
vendored
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,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
1
dist/assets/index-D_wrslmx.css
vendored
1
dist/assets/index-D_wrslmx.css
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/index-S7b0Xjr1.css
vendored
Normal file
1
dist/assets/index-S7b0Xjr1.css
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -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
4
dist/index.html
vendored
@@ -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);">
|
||||||
|
|||||||
4
dist/vendor/composer/installed.php
vendored
4
dist/vendor/composer/installed.php
vendored
@@ -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(),
|
||||||
|
|||||||
3
migrations/003_projects_files_permission.sql
Normal file
3
migrations/003_projects_files_permission.sql
Normal 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');
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
614
src/admin/components/ProjectFileManager.jsx
Normal file
614
src/admin/components/ProjectFileManager.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 "{order.order_number}"? 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"
|
||||||
|
|||||||
@@ -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 "{deleteConfirm.order?.order_number}"? 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"
|
||||||
|
|||||||
@@ -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 "{project.project_number} – {project.name}"? 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"
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user